diff --git a/.factory/settings.json b/.factory/settings.json new file mode 100644 index 0000000000..6bf84ef957 --- /dev/null +++ b/.factory/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "core@factory-plugins": true + } +} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..7eac5e6976 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Summary +Briefly describe what this PR does and why + +## Fixes +If this PR fixes an issue, mention it like: Fixes #123 + +## Snapshot +Add screenshots, GIFs, or videos demonstrating the changes (if applicable) + +## Type of change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] Chore (refactoring code, technical debt, workflow improvements) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Refactor (does not change functionality, e.g. code style improvements, linting) +- [ ] This change requires a documentation update + +## Mandatory Tasks + +- [ ] I have self-reviewed the code + +## Checklist + +- [ ] I have read the contributing guide +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have checked if my PR needs changes to the documentation +- [ ] I have added tests that prove my fix is effective or that my feature works diff --git a/AGENTS.md b/AGENTS.md index 4a99a3a78f..a40a61e85d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,329 +23,45 @@ optional_env: - CODEX_APPROVAL_POLICY --- -# Emdash +# Emdash Agent Guide -Cross-platform Electron app that orchestrates multiple CLI coding agents (Claude Code, Codex, Qwen Code, Amp, etc.) in parallel. Each agent runs in its own Git worktree for isolation. Also supports remote development over SSH. +Start here. Load only the linked `agents/` docs that are relevant to the task. -### Tech Stack +## Start Here -- **Runtime**: Electron 30.5.1, Node.js >=20.0.0 <23.0.0 (recommended: 22.20.0 via `.nvmrc`) -- **Frontend**: React 18, TypeScript 5.3, Vite 5, Tailwind CSS 3 -- **Backend**: Node.js, TypeScript, Drizzle ORM 0.32, SQLite3 5.1 -- **Editor**: Monaco Editor 0.55, **Terminal**: @xterm/xterm 6.0 + node-pty 1.0 -- **Native Modules**: node-pty, sqlite3, keytar 7.9 (require `pnpm run rebuild` after updates) -- **SSH**: ssh2 1.17 -- **UI**: Radix UI primitives, lucide-react icons, framer-motion +- Repo map: `agents/README.md` +- Setup and commands: `agents/quickstart.md` +- System overview: `agents/architecture/overview.md` +- Validation flow: `agents/workflows/testing.md` -## Quickstart +## Read By Task -1. `nvm use` (installs Node 22.20.0 if missing) or install Node 22.x manually. -2. `pnpm run d` to install dependencies and launch Electron + Vite. -3. If `pnpm run d` fails mid-stream, rerun `pnpm install`, then `pnpm run dev` (main + renderer). +- Main-process changes: `agents/architecture/main-process.md` +- Renderer/UI changes: `agents/architecture/renderer.md` +- Shared types or provider metadata: `agents/architecture/shared.md` +- Worktree behavior or `.emdash.json`: `agents/workflows/worktrees.md` +- SSH or remote project work: `agents/workflows/remote-development.md` +- Provider integration or CLI behavior: `agents/integrations/providers.md` +- MCP changes: `agents/integrations/mcp.md` -## Development Commands +## High-Risk Areas -```bash -# Quick start (installs deps, starts dev) -pnpm run d +- Database and migrations: `agents/risky-areas/database.md` +- PTY/session orchestration: `agents/risky-areas/pty.md` +- SSH and shell escaping: `agents/risky-areas/ssh.md` +- Auto-update and packaging: `agents/risky-areas/updater.md` -# Development (runs main + renderer concurrently) -pnpm run dev -pnpm run dev:main # Electron main process only (tsc + electron) -pnpm run dev:renderer # Vite dev server only (port 3000) +## Conventions -# Quality checks (ALWAYS run before committing) -pnpm run format # Format with Prettier -pnpm run lint # ESLint -pnpm run type-check # TypeScript type checking (uses tsconfig.json — renderer/shared/types) -pnpm exec vitest run # Run all tests +- IPC contract and typing: `agents/conventions/ipc.md` +- TypeScript and React norms: `agents/conventions/typescript.md` +- Config files and repo rules: `agents/conventions/config-files.md` -# Run a specific test -pnpm exec vitest run src/test/main/WorktreeService.test.ts +## Non-Negotiables -# Native modules -pnpm run rebuild # Rebuild native modules for Electron -pnpm run reset # Clean install (removes node_modules, reinstalls) - -# Building & Packaging -pnpm run build # Build main + renderer -pnpm run package:mac # macOS .dmg (arm64) -pnpm run package:linux # Linux AppImage/deb (x64) -pnpm run package:win # Windows nsis/portable (x64) -``` - -## Testing - -Tests use `vi.mock()` to stub `electron`, `DatabaseService`, `logger`, etc. Integration tests create real git repos in `os.tmpdir()`. No shared test setup file — mocks are per-file. - -- **Framework**: Vitest (configured in `vite.config.ts`, `environment: 'node'`) -- **Test locations**: `src/test/main/` (15 service tests), `src/test/renderer/` (3 UI tests), `src/main/utils/__tests__/` (2 utility tests) - -## Guardrails - -- **ALWAYS** run `pnpm run format`, `pnpm run lint`, `pnpm run type-check`, and `pnpm exec vitest run` before committing. -- **NEVER** modify `drizzle/meta/` or numbered migration files — always use `drizzle-kit generate`. -- **NEVER** modify `build/` entitlements or updater config without review. -- **ALWAYS** use feature branches or worktrees; never commit directly to `main`. -- Do limit edits to `src/**`, `docs/**`, or config files you fully understand; keep `dist/`, `release/`, and `build/` untouched. -- Don't modify telemetry defaults or updater logic unless intentional and reviewed. -- Don't run commands that mutate global environments (global package installs, git pushes) from agent scripts. -- Put temporary notes or scratch content in `.notes/` (gitignored). - -## Architecture - -### Process Model - -- **Main process** (`src/main/`): Electron main — IPC handlers, services, database, PTY management -- **Renderer process** (`src/renderer/`): React UI built with Vite — components, hooks, terminal panes -- **Shared** (`src/shared/`): Provider registry (21 agent definitions), PTY ID helpers, shared utilities - -### Boot Sequence - -`entry.ts` → `main.ts` → IPC registration → window creation - -- `entry.ts` — Sets app name (must happen before `app.getPath('userData')`, or Electron defaults to `~/Library/Application Support/Electron`). Monkey-patches `Module._resolveFilename` to resolve `@shared/*` and `@/*` path aliases at runtime in compiled JS. -- `main.ts` — Loads `.env`, fixes PATH for CLI discovery on macOS/Linux/Windows (adds Homebrew, npm global, nvm paths so agents like `gh`, `codex`, `claude` are found when launched from Finder), detects `SSH_AUTH_SOCK` from user's login shell, then initializes Electron windows and registers all IPC handlers. -- `preload.ts` — Exposes secure `electronAPI` to renderer via `contextBridge`. - -### Main Process (`src/main/`) - -**Key services** (`src/main/services/`): -- `WorktreeService.ts` — Git worktree lifecycle, file preservation patterns -- `WorktreePoolService.ts` — Worktree pooling/reuse for instant task starts -- `DatabaseService.ts` — All SQLite CRUD operations -- `ptyManager.ts` — PTY (pseudo-terminal) lifecycle, session isolation, agent spawning -- `SkillsService.ts` — Cross-agent skill installation and catalog management -- `GitHubService.ts` / `GitService.ts` — Git and GitHub operations via `gh` CLI -- `PrGenerationService.ts` — Automated PR generation -- `TaskLifecycleService.ts` — Task lifecycle orchestration -- `TerminalSnapshotService.ts` — Terminal state snapshots -- `TerminalConfigParser.ts` — Terminal configuration parsing -- `RepositoryManager.ts` — Repository management -- `RemotePtyService.ts` / `RemoteGitService.ts` — Remote development over SSH -- `ssh/` — SSH connection management, credentials (via keytar), host key verification - -Note: Some IPC handler files are colocated in `services/` (e.g., `worktreeIpc.ts`, `ptyIpc.ts`, `updateIpc.ts`, `lifecycleIpc.ts`, `planLockIpc.ts`, `fsIpc.ts`). - -**IPC Handlers** (`src/main/ipc/`): -- 25+ handler files total (19 in `ipc/` + 6 colocated in `services/`) covering app, db, git, github, browser, connections, project, settings, telemetry, SSH, Linear, Jira, skills, and more -- All return `{ success: boolean, data?: any, error?: string }` format -- Types defined in `src/renderer/types/electron-api.d.ts` (~1,870 lines) - -**Database** (`src/main/db/`): -- Schema: `schema.ts` — Migrations: `drizzle/` (auto-generated) -- Locations: macOS `~/Library/Application Support/emdash/emdash.db`, Linux `~/.config/emdash/emdash.db`, Windows `%APPDATA%\emdash\emdash.db` -- Override with `EMDASH_DB_FILE` env var - -### Renderer Process (`src/renderer/`) - -**Key components** (`components/`): -- `App.tsx` — Root orchestration (~790 lines), located at `src/renderer/App.tsx` -- `EditorMode.tsx` — Monaco code editor -- `ChatInterface.tsx` — Conversation UI -- `FileChangesPanel.tsx` / `ChangesDiffModal.tsx` — Diff visualization and review -- `CommandPalette.tsx` — Command/action palette -- `FileExplorer/` — File tree navigation -- `BrowserPane.tsx` — Webview preview -- `skills/` — Skills catalog and management UI -- `ssh/` — SSH connection UI components - -**Key hooks** (`hooks/`, 42 total): -- `useAppInitialization` — Two-round project/task loading (fast skeleton then full), restores last active project/task from localStorage -- `useTaskManagement` — Full task lifecycle (~864 lines): create, delete, rename, archive, restore. Handles optimistic UI removal with rollback, lifecycle teardown, PTY cleanup -- `useCliAgentDetection` — Detects which CLI agents are installed on the system -- `useInitialPromptInjection` / `usePendingInjection` — Manages initial prompt sent to agents on task start - -### Path Aliases - -**Important**: `@/*` resolves differently in main vs renderer: - -| Alias | tsconfig.json (renderer) | tsconfig.main.json (main) | -|-------|-------------------------|--------------------------| -| `@/*` | `src/renderer/*` | `src/*` | -| `@shared/*` | `src/shared/*` | `src/shared/*` | -| `#types/*` | `src/types/*` | _(not available)_ | -| `#types` | `src/types/index.ts` | _(not available)_ | - -At runtime in compiled main process, `entry.ts` monkey-patches `Module._resolveFilename` to map `@shared/*` → `dist/main/shared/*` and `@/*` → `dist/main/main/*`. - -Main uses `module: "CommonJS"` (required by Electron), renderer uses `module: "ESNext"` (Vite handles compilation). - -### IPC Pattern - -```typescript -// Main (src/main/ipc/exampleIpc.ts) -ipcMain.handle('example:action', async (_event, args) => { - try { - return { success: true, data: await service.doSomething(args) }; - } catch (error) { - return { success: false, error: error.message }; - } -}); - -// Renderer — call via window.electronAPI -const result = await window.electronAPI.exampleAction({ id: '123' }); -``` - -All new IPC methods must be declared in `src/renderer/types/electron-api.d.ts`. - -### Services - -Singleton classes with module-level export: -```typescript -export class ExampleService { /* ... */ } -export const exampleService = new ExampleService(); -``` - -## Provider Registry (`src/shared/providers/registry.ts`) - -All 21 CLI agents are defined as `ProviderDefinition` objects. Key fields: - -- `cli` — binary name, `commands` — detection commands (may differ from cli) -- `autoApproveFlag` — e.g. `--dangerously-skip-permissions` for Claude -- `initialPromptFlag` — how to pass the initial prompt (`-i`, positional, etc.) -- `useKeystrokeInjection` — `true` for agents with no CLI prompt flag (Amp, OpenCode); Emdash types the prompt into the TUI after startup -- `sessionIdFlag` — only Claude; enables multi-chat session isolation via `--session-id` -- `resumeFlag` — e.g. `-c -r` for Claude, `--continue` for Kilocode - -To add a new provider: add a definition here AND add any API key to the `AGENT_ENV_VARS` list in `ptyManager.ts`. - -## PTY Management (`src/main/services/ptyManager.ts`) - -Three spawn modes: -1. **`startPty()`** — Shell-based: `{cli} {args}; exec {shell} -il` (user gets a shell after agent exits) -2. **`startDirectPty()`** — Direct spawn without shell wrapper using cached CLI path. Faster. Falls back to `startPty` when CLI path isn't cached or `shellSetup` is configured. -3. **`startSshPty()`** — Wraps `ssh -tt {target}` for remote development. - -**Session isolation**: For Claude, generates a deterministic UUID from task/conversation ID for `--session-id`/`--resume`. Session map persisted to `{userData}/pty-session-map.json`. - -**PTY ID format** (`src/shared/ptyId.ts`): `{providerId}-main-{taskId}` or `{providerId}-chat-{conversationId}`. - -**Environment**: PTYs use a minimal env (not `process.env`). The `AGENT_ENV_VARS` list in `ptyManager.ts` is the definitive passthrough list for API keys. Data is flushed over IPC every 16ms. - -## Worktree System - -**WorktreeService** (`src/main/services/WorktreeService.ts`): -- Creates worktrees at `../worktrees/{slugged-name}-{3-char-hash}` on branch `{prefix}/{slugged-name}-{hash}` -- Branch prefix defaults to `emdash`, configurable in settings -- Preserves gitignored files (`.env`, `.envrc`, etc.) from main repo to worktree -- Custom preserve patterns via `.emdash.json` at project root: `{ "preservePatterns": [".claude/**"] }` - -**WorktreePoolService** (`src/main/services/WorktreePoolService.ts`): -Eliminates 3-7s worktree creation delay: -1. Pre-creates a `_reserve/{hash}` worktree in the background on project open -2. On task creation, instant `git worktree move` + `git branch -m` rename -3. Replenishes reserve in background after claiming -4. Reserves expire after 30 minutes; orphaned reserves cleaned on startup - -## Multi-Chat Conversations - -Tasks can have multiple conversation tabs, each with their own provider and PTY. Database `conversations` table tracks `isMain`, `provider`, `displayOrder`. For Claude, each conversation gets its own session UUID. - -## Skills System - -Implements the [Agent Skills](https://agentskills.io) standard — cross-agent reusable skill packages (`SKILL.md` with YAML frontmatter). - -- **Central storage**: `~/.agentskills/{skill-name}/`, metadata in `~/.agentskills/.emdash/` -- **Agent sync**: Symlinks from central storage into each agent's native directory (`~/.claude/commands/`, `~/.codex/skills/`, etc.) -- **Aggregated catalog**: Merges from OpenAI repo, Anthropic repo, and local user-created skills -- **Key files**: `src/shared/skills/` (types, validation, agent targets), `src/main/services/SkillsService.ts` (core logic), `src/main/ipc/skillsIpc.ts`, `src/renderer/components/skills/`, `src/main/services/skills/bundled-catalog.json` (offline fallback) - -## SSH Remote Development - -Orchestrates agents on remote machines over SSH. - -- **Connections**: Password, key, or agent auth. Credentials stored via `keytar` in OS keychain. -- **Remote worktrees**: Created at `/.emdash/worktrees//` on the server -- **Remote PTY**: Agent shells via `ssh2`'s shell API, streaming to UI in real-time -- **Key files**: `src/main/services/ssh/` (SshService, SshCredentialService, SshHostKeyService), `src/main/services/RemotePtyService.ts`, `src/main/services/RemoteGitService.ts`, `src/main/utils/shellEscape.ts` - -**Local-only (not yet remote)**: file diffs, file watching, branch push, worktree pooling, GitHub/PR features. - -**Security**: Shell args escaped via `quoteShellArg()` from `src/main/utils/shellEscape.ts`. Env var keys validated against `^[A-Za-z_][A-Za-z0-9_]*$`. Remote PTY restricted to allowlisted shell binaries. File access gated by `isPathSafe()`. - -## Database & Migrations - -- Schema in `src/main/db/schema.ts` → `pnpm exec drizzle-kit generate` to create migrations -- Browse: `pnpm exec drizzle-kit studio` -- Locations: macOS `~/Library/Application Support/emdash/emdash.db`, Linux `~/.config/emdash/emdash.db`, Windows `%APPDATA%\emdash\emdash.db` -- **NEVER** manually edit files in `drizzle/meta/` or numbered SQL migrations - -## Code Style - -- **TypeScript**: Strict mode enabled in both tsconfigs. Prefer explicit types over `any`. Type imports: `import type { Foo } from './bar'` -- **React**: Functional components with hooks. Both named and default exports are used. -- **File naming**: Components PascalCase (`FileExplorer.tsx`), hooks/utilities camelCase with `use` prefix (`useTaskManagement.ts`) or kebab-case (`use-toast.ts`). Tests: `*.test.ts` -- **Error handling**: Main → `log.error()` from `../lib/logger`, Renderer → `console.error()` or toast, IPC → `{ success: false, error }` -- **Styling**: Tailwind CSS classes - -## Project Configuration - -- **`.emdash.json`** at project root: `{ "preservePatterns": [".claude/**"] }` — controls which gitignored files are copied to worktrees. Also supports `shellSetup` for lifecycle scripts. -- **Branch prefix**: Configurable via app settings (`repository.branchPrefix`), defaults to `emdash` - -## Environment Variables - -All optional: -- `EMDASH_DB_FILE` — Override database file path -- `EMDASH_DISABLE_NATIVE_DB` — Disable native SQLite driver -- `EMDASH_DISABLE_CLONE_CACHE` — Disable clone caching -- `EMDASH_DISABLE_PTY` — Disable PTY support (used in tests) -- `TELEMETRY_ENABLED` — Toggle anonymous telemetry (PostHog) -- `CODEX_SANDBOX_MODE` / `CODEX_APPROVAL_POLICY` — Codex agent configuration - -## Hot Reload - -- **Renderer changes**: Hot-reload via Vite -- **Main process changes**: Require Electron restart (Ctrl+C → `pnpm run dev`) -- **Native modules**: Require `pnpm run rebuild` - -## CI/CD - -- **`code-consistency-check.yml`** (every PR): format check, type check, vitest (workflow name: "CI Check") -- **`release.yml`** (on `v*` tags): per-platform builds. Mac builds each arch separately to prevent native module architecture mismatches. Mac release includes signing + notarization. - -## Common Pitfalls - -1. **PTY resize after exit**: PTYs must be cleaned up on exit. Use `removePty()` in exit handlers. -2. **Worktree path resolution**: Always resolve paths from `WorktreeService`, not manually. -3. **IPC type safety**: Define all new IPC methods in `electron-api.d.ts`. -4. **Native module issues**: After updating node-pty/sqlite3/keytar, run `pnpm run rebuild`. Last resort: `pnpm run reset`. -5. **Monaco disposal**: Editor instances must be disposed to prevent memory leaks. -6. **CLI not found in agent**: If agents can't find `gh`, `codex`, etc., the PATH setup in `main.ts` may need updating for the platform. -7. **New provider integration**: Must add to registry in `src/shared/providers/registry.ts` AND add any API key to `AGENT_ENV_VARS` in `ptyManager.ts`. -8. **SSH shell injection**: All remote shell arguments must use `quoteShellArg()` from `src/main/utils/shellEscape.ts`. - -## Risky Areas - -- `src/main/db/**` + `drizzle/` — Schema migrations; mismatches can corrupt user data. -- `build/` entitlements and updater config — Incorrect changes break signing/auto-update. -- Native dependencies (`sqlite3`, `node-pty`, `keytar`) — Rebuilding is slow; avoid upgrading casually. -- PTY/terminal management — Race conditions or unhandled exits can kill agent runs. -- SSH services (`src/main/services/ssh/**`, `src/main/utils/shellEscape.ts`) — Security-critical: remote connections, credentials, shell command construction. - -## Git Workflow - -- Worktrees: `../worktrees/{workspace-name}-{hash}`, agents run there -- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`, `test:` -- Example: `fix(agent): resolve worktree path issue (#123)` - -## Key Configuration Files - -- `vite.config.ts` — Renderer build + Vitest test config -- `drizzle.config.ts` — Database migration config (supports `EMDASH_DB_FILE` override) -- `tsconfig.json` — Renderer/shared TypeScript config (`module: ESNext`, `noEmit: true` — Vite does compilation) -- `tsconfig.main.json` — Main process TypeScript config (`module: CommonJS` — required by Electron main) -- `tailwind.config.js` — Tailwind configuration -- `.nvmrc` — Node version (22.20.0) -- Electron Builder config is in `package.json` under `"build"` key - -## Pre-PR Checklist - -- [ ] Dev server runs: `pnpm run d` (or `pnpm run dev`) starts cleanly. -- [ ] Code is formatted: `pnpm run format`. -- [ ] Lint passes: `pnpm run lint`. -- [ ] Types check: `pnpm run type-check`. -- [ ] Tests pass: `pnpm exec vitest run`. -- [ ] No stray build artifacts or secrets committed. -- [ ] Documented any schema or config changes impacting users. +- Run `pnpm run format`, `pnpm run lint`, `pnpm run type-check`, and `pnpm exec vitest run` before merging. +- Do not hand-edit numbered Drizzle migrations or `drizzle/meta/`. +- Add renderer typings in `src/renderer/types/electron-api.d.ts` for any new IPC method. +- Treat `src/main/services/ptyManager.ts`, `src/main/services/ssh/**`, `src/main/db/**`, and updater code as high risk. +- Avoid editing `dist/`, `release/`, and `build/` unless the task is explicitly about packaging or updater/signing behavior. +- The docs app in `docs/` is separate from the Electron renderer and also defaults to port `3000`. diff --git a/LICENSE.md b/LICENSE.md index b020b7b722..3031ab3268 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,21 +1,191 @@ -MIT License - -Copyright (c) 2025 General Action, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 General Action, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 94bf85887a..4d69f811de 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
-[![MIT License](https://img.shields.io/badge/License-MIT-555555.svg?labelColor=333333&color=666666)](./LICENSE.md) +[![Apache 2.0 License](https://img.shields.io/badge/License-Apache_2.0-555555.svg?labelColor=333333&color=666666)](./LICENSE.md) [![Downloads](https://img.shields.io/github/downloads/generalaction/emdash/total?labelColor=333333&color=666666)](https://github.com/generalaction/emdash/releases) [![GitHub Stars](https://img.shields.io/github/stars/generalaction/emdash?labelColor=333333&color=666666)](https://github.com/generalaction/emdash) [![Last Commit](https://img.shields.io/github/last-commit/generalaction/emdash?labelColor=333333&color=666666)](https://github.com/generalaction/emdash/commits/main) @@ -33,7 +33,7 @@ Emdash is a provider-agnostic desktop app that lets you run multiple coding agents in parallel, each isolated in its own git worktree, either locally or over SSH on a remote machine. We call it an Agentic Development Environment (ADE). -Emdash supports 22 (and growing) CLI agents, such as Claude Code, Qwen Code, Amp, and Codex. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. +Emdash supports 23 CLI agents, including Claude Code, Qwen Code, Hermes Agent, Amp, and Codex. Users can directly pass Linear, GitHub, or Jira tickets to an agent, review diffs, test changes, create PRs, see CI/CD checks, and merge. **Develop on remote servers via SSH** @@ -74,29 +74,30 @@ Connect to remote machines via SSH/SFTP to work with remote codebases. Emdash su ### Supported CLI Providers -Emdash currently supports twenty-two CLI providers and we are adding new providers regularly. If you miss one, let us know or create a PR. +Emdash currently supports 23 CLI providers, and we are adding new ones regularly. If you miss one, let us know or create a PR. | CLI Provider | Status | Install | | ----------- | ------ | ----------- | -| [Amp](https://ampcode.com/manual) | ✅ Supported | npm install -g @sourcegraph/amp@latest | +| [Amp](https://ampcode.com/manual#install) | ✅ Supported | npm install -g @sourcegraph/amp@latest | | [Auggie](https://docs.augmentcode.com/cli/overview) | ✅ Supported | npm install -g @augmentcode/auggie | | [Autohand Code](https://autohand.ai/code/) | ✅ Supported | npm install -g autohand-cli | | [Charm](https://github.com/charmbracelet/crush) | ✅ Supported | npm install -g @charmland/crush | | [Claude Code](https://docs.anthropic.com/claude/docs/claude-code) | ✅ Supported | curl -fsSL https://claude.ai/install.sh | bash | | [Cline](https://docs.cline.bot/cline-cli/overview) | ✅ Supported | npm install -g cline | | [Codebuff](https://www.codebuff.com/docs/help/quick-start) | ✅ Supported | npm install -g codebuff | -| [Codex](https://developers.openai.com/codex/cli/) | ✅ Supported | npm install -g @openai/codex | +| [Codex](https://github.com/openai/codex) | ✅ Supported | npm install -g @openai/codex | | [Continue](https://docs.continue.dev/guides/cli) | ✅ Supported | npm i -g @continuedev/cli | | [Cursor](https://cursor.com/cli) | ✅ Supported | curl https://cursor.com/install -fsS | bash | | [Droid](https://docs.factory.ai/cli/getting-started/quickstart) | ✅ Supported | curl -fsSL https://app.factory.ai/cli | sh | | [Gemini](https://github.com/google-gemini/gemini-cli) | ✅ Supported | npm install -g @google/gemini-cli | -| [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/set-up/installing-github-copilot-in-the-cli) | ✅ Supported | npm install -g @github/copilot | -| [Goose](https://github.com/block/goose) | ✅ Supported | curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash | +| [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) | ✅ Supported | npm install -g @github/copilot | +| [Goose](https://block.github.io/goose/docs/quickstart/) | ✅ Supported | curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | bash | +| [Hermes Agent](https://hermes-agent.nousresearch.com/docs/) | ✅ Supported | curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash | | [Kilocode](https://kilo.ai/docs/cli) | ✅ Supported | npm install -g @kilocode/cli | -| [Kimi](https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html) | ✅ Supported | uv tool install --python 3.13 kimi-cli | -| [Kiro](https://kiro.dev/docs/cli/) | ✅ Supported | curl -fsSL https://cli.kiro.dev/install | bash | +| [Kimi](https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html) | ✅ Supported | uv tool install kimi-cli | +| [Kiro (AWS)](https://kiro.dev/docs/cli/) | ✅ Supported | curl -fsSL https://cli.kiro.dev/install | bash | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ Supported | curl -LsSf https://mistral.ai/vibe/install.sh | bash | -| [OpenCode](https://opencode.ai/docs/) | ✅ Supported | npm install -g opencode-ai | +| [OpenCode](https://opencode.ai/docs/cli/) | ✅ Supported | npm install -g opencode-ai | | [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | ✅ Supported | npm install -g @mariozechner/pi-coding-agent | | [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ Supported | npm install -g @qwen-code/qwen-code | | [Rovo Dev](https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/) | ✅ Supported | acli rovodev auth login | diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000000..ea886141c6 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,29 @@ +# Agent Docs + +This directory is the system of record for agent-facing repo guidance. Keep topic pages small, specific, and mechanically checkable where possible. + +## Recommended Reading Order + +1. `quickstart.md` +2. `architecture/overview.md` +3. the task-specific page for the area you are changing + +## Directory Layout + +- `architecture/` + - system structure and major code ownership boundaries +- `workflows/` + - task-oriented procedures like testing, worktrees, and remote development +- `integrations/` + - provider, MCP, and external service guidance +- `risky-areas/` + - places where incorrect changes are expensive +- `conventions/` + - coding contracts and repo rules + +## Maintenance Rules + +- Prefer one page per concrete topic. +- Avoid volatile counts unless you can verify them cheaply. +- Link to the source-of-truth file paths. +- Update the smallest relevant page instead of expanding `AGENTS.md`. diff --git a/agents/architecture/main-process.md b/agents/architecture/main-process.md new file mode 100644 index 0000000000..b9a5e69025 --- /dev/null +++ b/agents/architecture/main-process.md @@ -0,0 +1,53 @@ +# Main Process + +## Primary Areas + +- Worktrees and lifecycle: + - `src/main/services/WorktreeService.ts` + - `src/main/services/WorktreePoolService.ts` + - `src/main/services/TaskLifecycleService.ts` + - `src/main/services/LifecycleScriptsService.ts` + - `src/main/services/ProjectPrep.ts` +- PTY and provider runtime: + - `src/main/services/ptyManager.ts` + - `src/main/services/ptyIpc.ts` + - `src/main/services/ConnectionsService.ts` + - `src/main/services/AgentEventService.ts` + - `src/main/services/ClaudeHookService.ts` + - `src/main/services/OpenCodeHookService.ts` + - `src/main/services/CodexSessionService.ts` + - `src/main/services/PlainService.ts` +- Integrations: + - `src/main/services/GitHubService.ts` + - `src/main/services/GitLabService.ts` + - `src/main/services/ForgejoService.ts` + - `src/main/services/LinearService.ts` + - `src/main/services/JiraService.ts` + - `src/main/services/PrGenerationService.ts` +- Platform/data: + - `src/main/services/DatabaseService.ts` + - `src/main/services/RepositoryManager.ts` + - `src/main/services/ProjectSettingsService.ts` + - `src/main/services/AutoUpdateService.ts` + - `src/main/services/ChangelogService.ts` + - `src/main/services/browserViewService.ts` + - `src/main/services/hostPreviewService.ts` +- Remote development: + - `src/main/services/RemotePtyService.ts` + - `src/main/services/RemoteGitService.ts` + - `src/main/services/ssh/` +- Skills and MCP: + - `src/main/services/SkillsService.ts` + - `src/main/services/McpService.ts` + +## IPC Structure + +- Main IPC files live in `src/main/ipc/`. +- Some handler files are colocated in `src/main/services/`, including `worktreeIpc.ts`, `ptyIpc.ts`, `updateIpc.ts`, `lifecycleIpc.ts`, `planLockIpc.ts`, and `fsIpc.ts`. +- There is also an RPC router in `src/shared/ipc/rpc` used for `db`, `appSettings`, and `changelog`. + +## When Editing Here + +- Check `agents/conventions/ipc.md` for handler contract and typing rules. +- Check `agents/risky-areas/pty.md` before touching PTY or provider spawn behavior. +- Check `agents/risky-areas/database.md` before changing persistence or migrations. diff --git a/agents/architecture/overview.md b/agents/architecture/overview.md new file mode 100644 index 0000000000..27de85a422 --- /dev/null +++ b/agents/architecture/overview.md @@ -0,0 +1,22 @@ +# Architecture Overview + +## Process Model + +- `src/main/`: Electron main process, IPC, services, database, PTY orchestration, updater, SSH, integrations +- `src/renderer/`: React UI, task views, terminals, diff review, settings, skills, MCP, kanban +- `src/shared/`: provider registry, IPC helpers, shared MCP/diff/SSH/task utilities +- `docs/`: separate Next.js and Fumadocs site + +## Boot Sequence + +`src/main/entry.ts` -> `src/main/main.ts` -> window/app lifecycle -> IPC registration -> renderer + +- `entry.ts` installs runtime alias resolution for compiled CommonJS output and sets the app name early. +- `main.ts` loads `.env`, normalizes PATH, initializes shell-derived env, database, updater, SSH, worktree pool, and IPC. +- `preload.ts` exposes `window.electronAPI` via `contextBridge`. + +## Read Next + +- Main process details: `main-process.md` +- Renderer details: `renderer.md` +- Shared modules and provider registry: `shared.md` diff --git a/agents/architecture/renderer.md b/agents/architecture/renderer.md new file mode 100644 index 0000000000..8977772db1 --- /dev/null +++ b/agents/architecture/renderer.md @@ -0,0 +1,38 @@ +# Renderer + +## Main Entry Points + +- `src/renderer/App.tsx`: top-level provider composition +- `src/renderer/views/Workspace.tsx`: main post-onboarding shell +- `src/renderer/components/MainContentArea.tsx`: switches between chat, multi-agent, project, settings, skills, MCP, kanban, and home views +- `src/renderer/components/ChatInterface.tsx`: single-task chat and terminal workflow +- `src/renderer/components/MultiAgentTask.tsx`: multi-agent task experience +- `src/renderer/components/ProjectMainView.tsx`: project dashboard when no task is active +- `src/renderer/components/TaskModal.tsx` and `TaskAdvancedSettings.tsx`: task creation and advanced options + +## Feature Areas + +- Diff review: + - `src/renderer/components/diff*` + - `src/renderer/components/FileChangesPanel.tsx` +- Skills: + - `src/renderer/components/skills/` +- MCP: + - `src/renderer/components/mcp/` +- Kanban: + - `src/renderer/components/kanban/` +- Integrations: + - `src/renderer/components/integrations/` +- SSH: + - `src/renderer/components/ssh/` + +## Supporting Structure + +- Context providers live under `src/renderer/contexts/`. +- Hooks live under `src/renderer/hooks/`. +- client-side state helpers and stores live under `src/renderer/lib/`. + +## When Editing Here + +- Keep renderer IPC usage in sync with `src/renderer/types/electron-api.d.ts`. +- If you change user-visible workflows, update the matching page in `docs/` when appropriate. diff --git a/agents/architecture/shared.md b/agents/architecture/shared.md new file mode 100644 index 0000000000..219df89d34 --- /dev/null +++ b/agents/architecture/shared.md @@ -0,0 +1,37 @@ +# Shared Modules + +## Main Shared Areas + +- Provider registry: + - `src/shared/providers/registry.ts` +- IPC helpers: + - `src/shared/ipc/` +- MCP types: + - `src/shared/mcp/` +- Diff, git, task, SSH, and text helpers: + - `src/shared/diff/` + - `src/shared/git/` + - `src/shared/task/` + - `src/shared/ssh/` + - `src/shared/text/` + +## Important Alias Rules + +`@/*` resolves differently in main and renderer: + +| Alias | Renderer | Main | +| --- | --- | --- | +| `@/*` | `src/renderer/*` | `src/*` | +| `@shared/*` | `src/shared/*` | `src/shared/*` | +| `#types/*` | `src/types/*` | unavailable | + +Runtime alias handling for compiled main output is set up in `src/main/entry.ts`. + +## Provider Registry Rules + +When adding a provider: + +1. update `src/shared/providers/registry.ts` +2. add any required env passthrough in `src/main/services/ptyManager.ts` +3. update renderer surfaces that assume provider metadata +4. add tests for non-standard spawn or detection behavior diff --git a/agents/conventions/config-files.md b/agents/conventions/config-files.md new file mode 100644 index 0000000000..659a7aebdb --- /dev/null +++ b/agents/conventions/config-files.md @@ -0,0 +1,21 @@ +# Config Files And Repo Rules + +## Key Files + +- `package.json` +- `vite.config.ts` +- `tsconfig.json` +- `tsconfig.main.json` +- `drizzle.config.ts` +- `tailwind.config.js` +- `.emdash.json` +- `.nvmrc` +- `.husky/` +- `.github/workflows/` +- `flake.nix` + +## Repo Rules + +- avoid editing `dist/`, `release/`, and `build/` unless the task is explicitly about packaging or signing +- the docs app in `docs/` is separate from the Electron renderer +- update the narrowest relevant page in `agents/` instead of growing `AGENTS.md` diff --git a/agents/conventions/ipc.md b/agents/conventions/ipc.md new file mode 100644 index 0000000000..86a647298d --- /dev/null +++ b/agents/conventions/ipc.md @@ -0,0 +1,26 @@ +# IPC Conventions + +## Core Contract + +All renderer-facing IPC methods must be declared in: + +- `src/renderer/types/electron-api.d.ts` + +Use the standard response envelope: + +```ts +return { success: true, data }; +return { success: false, error: message }; +``` + +## Main Locations + +- `src/main/ipc/` +- selected colocated handlers in `src/main/services/` +- shared RPC utilities in `src/shared/ipc/rpc` + +## Rules + +- keep handler names and renderer typing in sync +- prefer existing service boundaries over adding logic directly inside handlers +- update tests when handler shape or IPC wiring changes diff --git a/agents/conventions/typescript.md b/agents/conventions/typescript.md new file mode 100644 index 0000000000..1498a40e61 --- /dev/null +++ b/agents/conventions/typescript.md @@ -0,0 +1,20 @@ +# TypeScript And React Conventions + +## TypeScript + +- strict mode is enabled in both app tsconfigs +- prefer explicit types over `any` +- use `import type` where possible + +## Renderer + +- functional React components and hooks +- context providers under `src/renderer/contexts/` +- hooks under `src/renderer/hooks/` +- client-side stores and helpers under `src/renderer/lib/` + +## Naming + +- components: PascalCase +- hooks: `useX` camelCase or existing patterns like `use-toast.ts` +- tests: `*.test.ts` diff --git a/agents/integrations/mcp.md b/agents/integrations/mcp.md new file mode 100644 index 0000000000..d3e5bd7723 --- /dev/null +++ b/agents/integrations/mcp.md @@ -0,0 +1,28 @@ +# MCP + +## Main Files + +- `src/main/services/McpService.ts` +- `src/main/services/mcp/` +- `src/shared/mcp/` +- `src/renderer/components/mcp/` + +## Current Behavior + +- MCP server configs are read, adapted, merged, and written across supported agent ecosystems +- provider-specific config formats are handled through adapters in `src/main/services/mcp/` +- the renderer MCP UI manages installed servers and catalog entries +- save/remove operations use a 2-phase flow: + - read phase is atomic, any read/parse failure aborts before writes begin + - write phase is best-effort, provider writes continue and failures are reported after all attempts +- when a write phase partially succeeds, the thrown error reports both failed agents and any configs that were updated before the failure + +## Important Constraint + +- Codex currently supports stdio MCP servers only + +## Rules + +- do not assume all providers support the same MCP transport types +- keep canonical MCP data in shared types and adapt at the edges +- if you add provider-specific MCP behavior, update both service and UI compatibility handling diff --git a/agents/integrations/providers.md b/agents/integrations/providers.md new file mode 100644 index 0000000000..ad6fc149de --- /dev/null +++ b/agents/integrations/providers.md @@ -0,0 +1,32 @@ +# Providers + +## Source Of Truth + +- `src/shared/providers/registry.ts` +- `src/main/services/ConnectionsService.ts` +- `src/main/services/ptyManager.ts` + +## Provider Metadata Includes + +- CLI and detection commands +- version args +- install command and docs URL +- auto-approve flags +- initial prompt handling +- keystroke injection behavior +- resume and session flags +- optional plan activation and auto-start commands + +## Provider Runtime Notes + +- Claude uses deterministic `--session-id` values for conversation isolation. +- Codex session recovery uses `src/main/services/CodexSessionService.ts`. +- Claude and OpenCode use hook/config helpers to emit structured events back into Emdash. +- `src/main/services/AgentEventService.ts` forwards hook events to renderer windows and can show OS notifications. + +## Adding Or Changing A Provider + +1. update `src/shared/providers/registry.ts` +2. update allowlisted agent env vars in `src/main/services/ptyManager.ts` if needed +3. validate detection behavior in `ConnectionsService.ts` +4. add or update tests for any non-standard behavior diff --git a/agents/quickstart.md b/agents/quickstart.md new file mode 100644 index 0000000000..3ddc8519c8 --- /dev/null +++ b/agents/quickstart.md @@ -0,0 +1,44 @@ +# Quickstart + +## Toolchain + +- Node: `22.20.0` from `.nvmrc` +- Package manager: `pnpm@10.28.2` +- Electron app root: this repo +- Docs app: `docs/` + +## Core Commands + +```bash +pnpm run d +pnpm run dev +pnpm run dev:main +pnpm run dev:renderer +pnpm run build +pnpm run rebuild +pnpm run reset +``` + +## Validation Commands + +```bash +pnpm run format +pnpm run lint +pnpm run type-check +pnpm exec vitest run +``` + +## Docs Commands + +```bash +pnpm run docs +pnpm run docs:build +pnpm --dir docs run types:check +``` + +## Important Notes + +- `pnpm test` is a shortcut for `vitest run`. +- The docs app and the Electron renderer both default to port `3000`. +- After native dependency changes (`sqlite3`, `node-pty`, `keytar`), run `pnpm run rebuild`. +- Husky and lint-staged run formatting and linting on staged files during commit. diff --git a/agents/risky-areas/database.md b/agents/risky-areas/database.md new file mode 100644 index 0000000000..fcf64335b0 --- /dev/null +++ b/agents/risky-areas/database.md @@ -0,0 +1,28 @@ +# Risky Area: Database + +## Main Files + +- `src/main/services/DatabaseService.ts` +- `src/main/db/schema.ts` +- `src/main/db/` +- `drizzle/` + +## Rules + +- never hand-edit numbered migrations +- never hand-edit `drizzle/meta/` +- use `pnpm exec drizzle-kit generate` for new migrations +- treat schema invariants and data migrations as high risk + +## Current Behavior + +- database path is resolved by main-process db path helpers +- `EMDASH_DB_FILE` overrides the default location +- `DatabaseService.initialize()` validates schema expectations and can trigger a local reset flow on incompatibility + +## Verify With + +```bash +sed -n '1,240p' src/main/services/DatabaseService.ts +sed -n '1,240p' src/main/db/schema.ts +``` diff --git a/agents/risky-areas/pty.md b/agents/risky-areas/pty.md new file mode 100644 index 0000000000..85ec076341 --- /dev/null +++ b/agents/risky-areas/pty.md @@ -0,0 +1,26 @@ +# Risky Area: PTY And Sessions + +## Main Files + +- `src/main/services/ptyManager.ts` +- `src/main/services/ptyIpc.ts` +- `src/main/services/AgentEventService.ts` +- `src/main/services/ClaudeHookService.ts` +- `src/main/services/OpenCodeHookService.ts` +- `src/main/services/CodexSessionService.ts` + +## Core Risks + +- PTY cleanup and exit handling +- resize behavior +- shell quoting and Windows command wrapping +- tmux lifecycle +- provider-specific resume/session behavior +- env passthrough safety + +## Rules + +- use the allowlisted env passthrough model in `AGENT_ENV_VARS` +- do not weaken quoting or spawn behavior casually +- validate both direct spawn and shell-wrapped spawn cases when changing PTY startup logic +- confirm renderer event flow if hook payload or notification behavior changes diff --git a/agents/risky-areas/ssh.md b/agents/risky-areas/ssh.md new file mode 100644 index 0000000000..483c722d40 --- /dev/null +++ b/agents/risky-areas/ssh.md @@ -0,0 +1,17 @@ +# Risky Area: SSH And Shell Escaping + +## Main Files + +- `src/main/services/ssh/` +- `src/main/services/RemotePtyService.ts` +- `src/main/services/RemoteGitService.ts` +- `src/main/utils/shellEscape.ts` +- `src/main/utils/sshCommandValidation.ts` +- `src/main/utils/sshConfigParser.ts` + +## Rules + +- treat remote shell construction as security-sensitive +- use shared escaping and validation helpers +- do not bypass path-safety or shell validation helpers +- verify how a change affects both connection setup and command execution diff --git a/agents/risky-areas/updater.md b/agents/risky-areas/updater.md new file mode 100644 index 0000000000..f4df0a651d --- /dev/null +++ b/agents/risky-areas/updater.md @@ -0,0 +1,23 @@ +# Risky Area: Updater And Packaging + +## Main Files + +- `src/main/services/AutoUpdateService.ts` +- `src/main/services/ChangelogService.ts` +- `build/` +- `package.json` +- `.github/workflows/release.yml` +- `.github/workflows/windows-beta-build.yml` +- `.github/workflows/nix-build.yml` + +## Rules + +- avoid changing updater defaults casually +- treat signing, notarization, packaging targets, and native rebuild flow as release-critical +- keep build output directories and packaging config stable unless the task is explicitly about release behavior + +## Current Notes + +- macOS and Linux release jobs rebuild native modules for the target Electron version +- Windows beta builds intentionally use Node 20 in CI for native module stability +- changelog and auto-update behavior are separate but related surfaces in the app diff --git a/agents/workflows/remote-development.md b/agents/workflows/remote-development.md new file mode 100644 index 0000000000..c14cfb0edb --- /dev/null +++ b/agents/workflows/remote-development.md @@ -0,0 +1,26 @@ +# Remote Development + +## Main Files + +- `src/main/services/RemotePtyService.ts` +- `src/main/services/RemoteGitService.ts` +- `src/main/services/ssh/` +- `src/main/utils/shellEscape.ts` +- `src/main/utils/sshCommandValidation.ts` + +## Current Model + +- remote projects are backed by SSH connections +- remote worktrees live under `/.emdash/worktrees//` +- remote PTYs stream agent shells back to the renderer + +## Authentication And Storage + +- SSH credentials are managed through the SSH services and OS-backed secret storage +- host key handling is implemented under `src/main/services/ssh/` + +## Rules + +- treat all shell construction as security-sensitive +- use shared SSH and shell-escaping helpers instead of ad hoc quoting +- confirm whether a feature is local-only before assuming parity on remote projects diff --git a/agents/workflows/testing.md b/agents/workflows/testing.md new file mode 100644 index 0000000000..b3d83a19ad --- /dev/null +++ b/agents/workflows/testing.md @@ -0,0 +1,40 @@ +# Testing And Validation + +## Core Local Gate + +Run these before merging: + +```bash +pnpm run format +pnpm run lint +pnpm run type-check +pnpm exec vitest run +``` + +## Test Layout + +- main-process tests: `src/test/main/` +- renderer-focused tests: `src/test/renderer/` +- utility tests: `src/main/utils/__tests__/` + +## Current Setup + +- Vitest config is in `vite.config.ts`. +- Tests run with `environment: 'node'`. +- Included test files match `src/**/*.test.ts`. +- Tests use per-file `vi.mock()` setup. +- Integration-style tests create temporary repos and worktrees in `os.tmpdir()`. + +## CI Notes + +- `.github/workflows/code-consistency-check.yml` currently enforces: + - `pnpm run format:check` + - `pnpm run type-check` + - `pnpm exec vitest run` +- Lint is still expected locally even though it is not enabled in that workflow yet. + +## Focused Validation + +- after IPC changes: rerun the affected Vitest file and confirm `electron-api.d.ts` +- after worktree or PTY changes: rerun the closest main-process service tests +- after docs changes: run `pnpm --dir docs run types:check` diff --git a/agents/workflows/worktrees.md b/agents/workflows/worktrees.md new file mode 100644 index 0000000000..89178d0b35 --- /dev/null +++ b/agents/workflows/worktrees.md @@ -0,0 +1,34 @@ +# Worktrees + +## Main Files + +- `src/main/services/WorktreeService.ts` +- `src/main/services/WorktreePoolService.ts` +- `src/main/services/LifecycleScriptsService.ts` +- `.emdash.json` + +## Current Behavior + +- task worktrees are created under `../worktrees/` +- branch prefix defaults to `emdash` and is configurable in app settings +- selected gitignored files are preserved into worktrees +- reserve worktrees are pre-created to reduce task startup latency + +## `.emdash.json` + +Current supported keys: + +- `preservePatterns` +- `scripts.setup` +- `scripts.run` +- `scripts.stop` +- `scripts.teardown` +- `shellSetup` +- `tmux` + +## Rules + +- do not hardcode worktree paths; use service helpers +- use lifecycle config for repo-specific bootstrap and teardown behavior +- `shellSetup` runs inside each PTY before the interactive shell starts +- tmux wrapping is project-configurable and affects PTY lifecycle behavior diff --git a/docs/.gitignore b/docs/.gitignore index 906741640e..bae61bd644 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -19,4 +19,5 @@ node_modules src/routeTree.gen.ts .source/ next-env.d.ts +*.tsbuildinfo public/md-src/ diff --git a/docs/app/[[...slug]]/layout.tsx b/docs/app/[[...slug]]/layout.tsx index b70a3b4560..ecdcbe89bb 100644 --- a/docs/app/[[...slug]]/layout.tsx +++ b/docs/app/[[...slug]]/layout.tsx @@ -1,11 +1,41 @@ import { source } from '@/lib/source'; import { DocsLayout } from 'fumadocs-ui/layouts/docs'; import type { ReactNode } from 'react'; +import type { Node } from 'fumadocs-core/page-tree'; import { baseOptions } from '@/lib/layout.shared'; +const betaPages = new Set(['/automations']); + +function addBetaBadges(nodes: Node[]): Node[] { + return nodes.map((node) => { + if (node.type === 'page' && betaPages.has(node.url)) { + return { + ...node, + name: ( + <> + {node.name} + + Beta + + + ), + }; + } + if (node.type === 'folder' && node.children) { + return { ...node, children: addBetaBadges(node.children) }; + } + return node; + }); +} + export default function Layout({ children }: { children: ReactNode }) { + const tree = { + ...source.pageTree, + children: addBetaBadges(source.pageTree.children), + }; + return ( - + {children} ); diff --git a/docs/app/[[...slug]]/page.tsx b/docs/app/[[...slug]]/page.tsx index fea5b88f57..0919990738 100644 --- a/docs/app/[[...slug]]/page.tsx +++ b/docs/app/[[...slug]]/page.tsx @@ -3,7 +3,9 @@ import { DocsPage, DocsBody, DocsDescription, DocsTitle } from 'fumadocs-ui/page import { notFound } from 'next/navigation'; import defaultMdxComponents from 'fumadocs-ui/mdx'; import type { Metadata } from 'next'; +import type { MDXComponents } from 'mdx/types'; import { CopyMarkdownButton } from '@/components/CopyMarkdownButton'; +import { CopyEmailButton } from '@/components/CopyEmailButton'; import { LastUpdated } from '@/components/LastUpdated'; import { getGithubLastEdit } from 'fumadocs-core/content/github'; @@ -42,6 +44,11 @@ export default async function Page({ params }: { params: Promise<{ slug?: string lastModified = (await getLastModifiedFromGitHub(filePath)) ?? undefined; } + const components = { + ...defaultMdxComponents, + CopyEmailButton, + } as unknown as MDXComponents; + return ( - + {lastModified && } diff --git a/docs/components/CopyEmailButton.tsx b/docs/components/CopyEmailButton.tsx new file mode 100644 index 0000000000..e86461e110 --- /dev/null +++ b/docs/components/CopyEmailButton.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; + +export function CopyEmailButton({ email }: { email: string }) { + const [state, setState] = React.useState<'idle' | 'copied'>('idle'); + + function onCopy() { + navigator.clipboard.writeText(email); + setState('copied'); + window.setTimeout(() => setState('idle'), 1200); + } + + return ( + + ); +} diff --git a/docs/content/docs/automations.mdx b/docs/content/docs/automations.mdx new file mode 100644 index 0000000000..cb3293ed7f --- /dev/null +++ b/docs/content/docs/automations.mdx @@ -0,0 +1,81 @@ +--- +title: Automations +description: Schedule recurring tasks in Emdash +--- + + + Beta + + +Automations let you run the same task on a schedule. Each automation stores a prompt, one project, one agent, and a schedule. When it fires, Emdash creates a background task and starts the agent automatically. + +## Creating an automation + +1. Open **Automations** from the left sidebar +2. Click **New Automation** +3. Enter a title and prompt +4. Pick a project +5. Choose an agent +6. Choose **Worktree** or **Direct** +7. Set a schedule +8. Click **Create** + +Use `⌘/Ctrl+Enter` to create quickly. + +## Schedule types + +Emdash supports four schedule types: + +- **Hourly** +- **Daily** +- **Weekly** +- **Monthly** + +Automations run in the background and appear in the **Active** list on the Automations page. + +## Worktree or Direct + +**Worktree** creates a separate git worktree for each run, just like a normal [task](/tasks). This is the safer default because changes stay isolated from your main checkout. + +**Direct** runs against the main project checkout without creating a new worktree. + +If your project needs preserved files, setup scripts, shell setup, or tmux configuration, set that up in [Project Configuration](/project-config) first. + +## Managing automations + +Each automation row supports: + +- **Run now** +- **Pause / Resume** +- **Edit** +- **Delete** + +When an automation runs: + +- a background task is created +- the selected agent starts automatically +- the task appears in the **Automation Tasks** section +- the run count opens the run log history + +## Important behavior + +- Emdash must be open for automations to run on time. Automations are scheduled by the app, not by your operating system. +- If Emdash was closed during a scheduled run, it will catch up once when you reopen the app. +- Each automation belongs to a single project. +- Frequent schedules can create a lot of tasks and worktrees. Review and clean up old runs regularly. + +## Recommended workflow + +Before scheduling an automation, test the prompt in a normal [task](/tasks) first. Make sure it behaves the way you expect, then turn it into an automation. + +Start with a slower schedule like daily or weekly before moving to something more frequent. + +If the automation starts local servers, use `EMDASH_PORT` in your [Project Configuration](/project-config) so parallel runs do not collide. + +## Good examples + +- Daily code review +- Dependency update checks +- Docs coverage checks +- Security scans +- Performance audits diff --git a/docs/content/docs/bring-your-own-infrastructure.mdx b/docs/content/docs/bring-your-own-infrastructure.mdx new file mode 100644 index 0000000000..64f551e9d4 --- /dev/null +++ b/docs/content/docs/bring-your-own-infrastructure.mdx @@ -0,0 +1,323 @@ +--- +title: Bring Your Own Infrastructure +description: Automatically provision and tear down remote workspaces for each task using your own infrastructure +--- + +
+ Contact us to get started: + +
+ +Plug in custom shell scripts that create and destroy remote environments on demand. When a developer creates a task, Emdash runs your provision script, connects the terminal via SSH, and runs coding agents inside the workspace. When the task is deleted, Emdash runs your teardown script to clean up. + +This works with any infrastructure backend: cloud VMs, Kubernetes pods, container-based dev environments, or internal workspace platforms. You just provide the scripts. + +
+ AWS + Hetzner + Azure + Docker +
+ +## How It Works + +1. Developer creates a task with **"Remote workspace"** enabled +2. Emdash runs your **provision script** as a child process +3. Script progress (stderr) streams live in the UI +4. Script outputs JSON (stdout) with SSH connection details +5. Emdash connects the terminal to the workspace via SSH +6. Coding agents run inside the remote workspace +7. When the task is deleted, Emdash runs your **teardown script** + +### Compared to Remote Projects + +| | [Remote Projects](/docs/remote-projects) | Workspace Provider | +| -------------------- | ---------------------------------------- | ------------------------------- | +| **Lifecycle** | Persistent server, shared across tasks | Per-task, provisioned on demand | +| **Setup** | Manual connection in Settings | Automated via scripts | +| **Infra management** | You manage the server | Scripts handle create/destroy | +| **Use case** | Dedicated dev server | Ephemeral, isolated workspaces | + +Both features share the same remote capabilities once connected: terminal access, coding agents, Git operations, and file browsing all work identically. + +## Setup + +### Step 1: Create Your Scripts + +Create two shell scripts in your project root (or anywhere accessible from the project directory). + +#### Provision Script + +The provision script creates a workspace and outputs SSH connection details as JSON to stdout. All log messages must go to stderr. + +**Environment variables provided by Emdash:** + +| Variable | Description | +| ----------------- | ---------------------------------------------------- | +| `EMDASH_TASK_ID` | Unique identifier for the task | +| `EMDASH_REPO_URL` | Repository URL (e.g., `git@github.com:org/repo.git`) | +| `EMDASH_BRANCH` | Branch name for this task | +| `EMDASH_BASE_REF` | Base branch (e.g., `main`) | + +**Required output (JSON to stdout):** + +```json +{ + "host": "workspace-hostname-or-ip", + "id": "optional-external-id", + "port": 22, + "username": "dev", + "worktreePath": "/path/to/repo/on/workspace" +} +``` + +| Field | Required | Description | +| -------------- | -------- | ---------------------------------------------------------------- | +| `host` | Yes | Hostname, IP address, or SSH config alias | +| `id` | No | External identifier (passed to teardown as `EMDASH_INSTANCE_ID`) | +| `port` | No | SSH port (default: 22) | +| `username` | No | SSH username (default: current user) | +| `worktreePath` | No | Path to the repository on the workspace | + +**Example provision script:** + +```bash +#!/bin/bash +set -euo pipefail + +echo "[$(date)] Creating workspace for $EMDASH_BRANCH..." >&2 + +# Replace this section with your infrastructure commands +WORKSPACE_ID="ws-$(date +%s)" +HOST="dev-${WORKSPACE_ID}.internal.example.com" + +echo "[$(date)] Provisioning VM..." >&2 +# your-cli create-workspace --id "$WORKSPACE_ID" \ +# --repo "$EMDASH_REPO_URL" --branch "$EMDASH_BRANCH" + +echo "[$(date)] Ready!" >&2 + +# Output JSON to stdout (this is what Emdash parses) +cat <&2`) — Emdash streams these lines live in the UI +- Only the JSON object goes to **stdout** — this is parsed for connection details +- Exit with code **0** on success, non-zero on failure +- Timeout: **5 minutes** + +#### Teardown Script + +The teardown script destroys the workspace when the task is deleted. + +**Environment variables provided by Emdash:** + +| Variable | Description | +| -------------------- | ----------------------------------------------------------------------- | +| `EMDASH_INSTANCE_ID` | The `id` from your provision output (or `host` if no `id` was provided) | +| `EMDASH_TASK_ID` | The task identifier (same value passed to the provision script) | + +**Example teardown script:** + +```bash +#!/bin/bash +set -euo pipefail + +echo "[$(date)] Destroying workspace $EMDASH_INSTANCE_ID..." >&2 +# your-cli delete-workspace "$EMDASH_INSTANCE_ID" +echo "[$(date)] Done." >&2 +``` + +Make both scripts executable: + +```bash +chmod +x provision.sh teardown.sh +``` + +### Step 2: Configure `.emdash.json` + +Add the workspace provider configuration to `.emdash.json` in your project root: + +```json +{ + "workspaceProvider": { + "type": "script", + "provisionCommand": "./provision.sh", + "terminateCommand": "./teardown.sh" + } +} +``` + +Alternatively, configure via the Emdash UI: + +1. Open your project +2. Click the **gear icon** (Project Settings) +3. Scroll to the **Workspace Provider** section +4. Enter your provision and terminate commands +5. Save + +### Step 3: Create a Task + +1. Click **New Task** +2. In the task creation dialog, expand **Advanced Settings** +3. Check **"Remote workspace (provision via script)"** +4. Create the task + +Emdash will: + +1. Show a provisioning overlay with live progress from your script's stderr +2. Parse the JSON output to get connection details +3. Connect the terminal to the workspace via SSH +4. Start your coding agent inside the workspace + +## Workspace Requirements + +For the best experience, ensure your provisioned workspaces have these tools installed: + +### Required + +- **SSH server** — Emdash connects via your system's `ssh` command +- **Git** — For repository operations, configured with `user.name` and `user.email` +- **A coding agent** — At least one CLI agent ([Claude Code, Codex, etc.](/docs/providers)) + +### Recommended + +- **GitHub CLI (`gh`)** — For PR creation and GitHub operations from the Emdash UI +- **tmux** — Emdash automatically uses tmux for workspace sessions to preserve state across reconnects + +### GitHub Access (for push/pull) + +If your workflow involves pushing code, configure SSH access to GitHub on the workspace: + +```bash +ssh-keygen -t ed25519 -C "workspace" -f ~/.ssh/id_github -N "" +# Add ~/.ssh/id_github.pub to GitHub (deploy key or user key) +git config --global core.sshCommand "ssh -i /home/dev/.ssh/id_github" +ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null +``` + +## SSH Authentication + +Emdash connects to workspaces using your local system's `ssh` command, which means: + +- **SSH config aliases work** — Your provision script can return an SSH config `Host` alias as the `host` field +- **SSH agent works** — Keys in your local SSH agent (including macOS Keychain) are available +- **~/.ssh/config works** — ProxyJump, custom ports, identity files, and other SSH config directives are respected + +No additional SSH configuration is needed in Emdash itself. + +## Task Lifecycle + +### Creating a Task + +When you create a task with remote workspace enabled: + +1. Provision script runs (progress streams in the UI) +2. On success: SSH connection is established, agent starts in the workspace +3. On failure: error is shown in the UI with a retry option + +### Deleting a Task + +When you delete a workspace-backed task: + +1. Teardown script runs with `EMDASH_INSTANCE_ID` +2. On success: workspace is destroyed, database is cleaned up +3. On failure: instance is marked as error, task is kept for retry + +### App Restart + +If Emdash is restarted while workspaces are active: + +- **Provisioning** instances are marked as error (the child process is gone) +- **Ready** instances remain — the SSH connection is re-established when you open the task + +## Troubleshooting + +### Provision Script Fails + +**Symptoms:** Error overlay with script output + +**Solutions:** + +1. Check the error message in the overlay — it includes the last 500 chars of stderr +2. Run the script manually to debug: + ```bash + EMDASH_TASK_ID=test EMDASH_REPO_URL=git@github.com:org/repo.git \ + EMDASH_BRANCH=test-branch EMDASH_BASE_REF=main \ + ./provision.sh + ``` +3. Verify the JSON output is valid: pipe stdout through `jq` +4. Ensure log output goes to stderr, not stdout + +### SSH Connection Fails After Provisioning + +**Symptoms:** Terminal shows SSH error after provision succeeds + +**Solutions:** + +1. Verify you can SSH manually: `ssh ` (using the host from your script output) +2. Check your `~/.ssh/config` for the host +3. Verify your SSH agent has the right key loaded: `ssh-add -l` +4. If using an SSH config alias, ensure the alias is configured on the machine running Emdash + +### Script Times Out + +Provision scripts have a 5-minute timeout and teardown scripts have a 2-minute timeout. If your infrastructure takes longer, optimize the provisioning pipeline or pre-warm environments. + +### "Invalid JSON" Error + +**Symptoms:** "Provision script output is not valid JSON" + +**Solutions:** + +1. Ensure **only** JSON is printed to stdout — all log messages must use `>&2` +2. Check for stray `echo` or `printf` statements without `>&2` +3. Validate your output: `./provision.sh 2>/dev/null | jq .` + +## Script Contract Reference + +### Provision Script + +| Aspect | Details | +| --------------------- | ----------------------------------------------------------------------- | +| **Execution** | `bash -c ` | +| **Working directory** | Project root | +| **Env vars** | `EMDASH_TASK_ID`, `EMDASH_REPO_URL`, `EMDASH_BRANCH`, `EMDASH_BASE_REF` | +| **Stdout** | JSON object with at least `host` field | +| **Stderr** | Log lines (streamed to UI) | +| **Exit code** | 0 = success, non-zero = failure | +| **Timeout** | 5 minutes | + +### Teardown Script + +| Aspect | Details | +| --------------------- | -------------------------------------- | +| **Execution** | `bash -c ` | +| **Working directory** | Project root | +| **Env vars** | `EMDASH_INSTANCE_ID`, `EMDASH_TASK_ID` | +| **Stdout** | (ignored) | +| **Stderr** | Log lines | +| **Exit code** | 0 = success, non-zero = failure | +| **Timeout** | 2 minutes | diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index b1caebf069..e6ff48ad1b 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -7,7 +7,7 @@ Emdash is an open source desktop app for running multiple coding agents in paral ## Capabilities -- **[Parallel agents](/parallel-agents):** Run multiple agents simultaneously, each in its own worktree +- **[Parallel agents](/tasks):** Run multiple agents simultaneously, each in its own worktree - **[Provider support](/providers):** Use any of 18+ CLI-based agents: Claude Code, Codex, Gemini, OpenCode, and more - **[Best-of-N](/best-of-n):** Run multiple agents on the same task and pick the best result - **[Diff view](/diff-view):** Review changes across agents side-by-side diff --git a/docs/content/docs/issues.mdx b/docs/content/docs/issues.mdx index 393580aef5..0a38311730 100644 --- a/docs/content/docs/issues.mdx +++ b/docs/content/docs/issues.mdx @@ -63,4 +63,6 @@ GitHub Issues requires the GitHub CLI (`gh`). Emdash auto-installs it if missing ## Auto-Closing Issues -When you create a PR from a task that has a linked issue, Emdash automatically closes the associated GitHub or Linear issue. This only applies to issues that were passed to the task at creation time. +When you create a PR from a task that has a linked issue, Emdash automatically closes the associated GitHub or Linear issue by default. This only applies to issues that were passed to the task at creation time. + +If your workflow closes issues later, after environment testing, deployment, or customer approval, you can disable this in **Settings → Repository → Auto-close linked issues on PR creation**. diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx new file mode 100644 index 0000000000..168c5a0cd7 --- /dev/null +++ b/docs/content/docs/mcp.mdx @@ -0,0 +1,84 @@ +--- +title: MCP Servers +description: Connect your agents to external tools and data sources via MCP +--- + +MCP (Model Context Protocol) lets your coding agents connect to external tools and data sources — databases, APIs, browsers, and more. Emdash manages MCP server configs across all your installed agents from a single place, so you configure once and every agent picks it up. + +## Getting Started + +Open the MCP view by clicking **MCP** in the left sidebar. You'll see two sections: + +- **Added** — servers you've already configured +- **Recommended** — a curated catalog of popular MCP servers ready to add + +Use the search box to filter by name or description. + +## Adding a Server from the Catalog + +1. Find a server in the **Recommended** section (e.g. Playwright, Supabase, Sentry) +2. Click **Add** on the card +3. The modal opens pre-filled with the server's default config +4. Fill in any required credentials (marked with an asterisk) +5. Select which agents to sync to under **Sync to agents** +6. Click **Add** + +Emdash writes the config into each selected agent's native config file. The server is available in your next agent session. + +## Adding a Custom Server + +1. Click **Custom MCP** in the toolbar +2. Enter a name and choose the transport type: + - **stdio** — runs a local process (provide a command and optional arguments) + - **http** — connects to a remote URL (provide the endpoint and optional headers) +3. Add any environment variables the server needs +4. Select target agents and click **Add** + +## Editing and Removing + +Click an installed server card to edit its config — change credentials, update the URL, or adjust which agents it syncs to. Click **Remove** to delete the server from all agents. + +## Agent Sync + +When you add or edit a server, Emdash translates the config into each agent's native format and writes it to the correct location: + +| Agent | Config file | +| -------------- | ---------------------------------- | +| Claude Code | `~/.claude.json` | +| Cursor | `~/.cursor/mcp.json` | +| Codex | `~/.codex/config.toml` | +| Amp | `~/.config/amp/settings.json` | +| Gemini | `~/.gemini/settings.json` | +| Qwen | `~/.qwen/settings.json` | +| OpenCode | `~/.config/opencode/opencode.json` | +| GitHub Copilot | `~/.copilot/mcp-config.json` | +| Droid | `~/.droid/settings.json` | + +This means MCP servers configured through Emdash also work when you run these agents outside of Emdash. + +## Transport Compatibility + +MCP servers use one of two transports: **stdio** (local process) or **http** (remote URL). Most agents support both, but Codex currently only supports stdio servers. When adding an HTTP server, incompatible agents are automatically disabled in the agent selector. + +## Catalog Servers + +The built-in catalog includes 40+ servers for common services: + +- **Browser & DevTools**: Playwright, Chrome DevTools +- **Databases & Data**: Supabase, PlanetScale, MotherDuck, BigQuery +- **Hosting & Infrastructure**: Vercel, Netlify, Cloudflare, AWS Marketplace +- **Monitoring & Analytics**: Sentry, PostHog, Honeycomb, Amplitude +- **Project Management**: Linear, Asana, ClickUp, Notion, Jira (Atlassian) +- **Design & Content**: Figma, Canva, Miro, Webflow, Sanity, Cloudinary, WordPress +- **Communication**: Slack, Intercom +- **Payments & Auth**: Stripe, Clerk +- **AI & Search**: Hugging Face, Exa, Context7 +- **Automation**: Make + +Each catalog entry includes a link to the server's documentation for setup details. + +## Tips + +- You can sync a single MCP server to multiple agents at once — no need to configure each one separately. +- Environment variables with credential keys are highlighted in the modal so you know what to fill in. +- Click **Refresh** to re-detect installed agents if you've installed a new CLI since opening Emdash. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 93a024fcfa..29771fabc1 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -5,6 +5,7 @@ "installation", "---Using Emdash---", "tasks", + "automations", "issues", "---Capabilities---", "best-of-n", @@ -14,8 +15,10 @@ "file-editor", "kanban-view", "skills", + "mcp", "ci-checks", "remote-projects", + "bring-your-own-infrastructure", "tmux-sessions", "---More---", "contributing", diff --git a/docs/content/docs/project-config.mdx b/docs/content/docs/project-config.mdx index 8f1767d52e..78acc2cab3 100644 --- a/docs/content/docs/project-config.mdx +++ b/docs/content/docs/project-config.mdx @@ -47,10 +47,11 @@ This means you can safely modify environment files in a worktree without worryin ## Script fields -Project config has three script fields: +Project config has four script fields: - `Setup script`: runs once right after task creation (good for installs/bootstrap) - `Run script`: long-running command you start/stop from the task terminal (good for dev servers) +- `Stop script`: runs when the run script is stopped, before the process is killed (good for graceful shutdown) - `Teardown script`: runs when a task is deleted or archived The setup script runs as soon as the worktree is ready, in parallel with the agent. diff --git a/docs/content/docs/skills.mdx b/docs/content/docs/skills.mdx index c4a928c47d..245cf7ea61 100644 --- a/docs/content/docs/skills.mdx +++ b/docs/content/docs/skills.mdx @@ -24,8 +24,9 @@ Open the Skills view by clicking **Skills** (puzzle icon) in the left sidebar. Y - [OpenAI skill library](https://github.com/openai/skills/) - [Anthropic skill library](https://github.com/anthropics/skills/) +- [skills.sh](https://skills.sh/) — a community skill registry -Use the search box to filter by name or description. Click **Refresh** to fetch the latest skills from GitHub. +Click **Refresh** to fetch the latest skills from the OpenAI and Anthropic catalogs. Use the search box to filter by name or description. When you search, Emdash also queries the skills.sh API to surface community-contributed skills beyond the built-in catalogs. If you want to use skills from another library, feel free to let us know through the feedback modal in the app. diff --git a/docs/content/docs/tmux-sessions.mdx b/docs/content/docs/tmux-sessions.mdx index 022c03a6c5..03978481ae 100644 --- a/docs/content/docs/tmux-sessions.mdx +++ b/docs/content/docs/tmux-sessions.mdx @@ -1,5 +1,5 @@ --- -title: tmux Sessions +title: Tmux Sessions description: Persistent agent sessions with tmux --- diff --git a/docs/package.json b/docs/package.json index b8960129c1..163aa7913f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,7 +16,7 @@ "fumadocs-mdx": "14.0.3", "fumadocs-ui": "16.1.0", "lucide-static": "^0.552.0", - "next": "^16.0.8", + "next": "^16.1.6", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwind-merge": "^3.3.1" diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 79057fef68..777056edcf 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -1,704 +1,484 @@ -lockfileVersion: '6.0' +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false -dependencies: - fumadocs-core: - specifier: 16.1.0 - version: 16.1.0(@types/react@19.2.14)(next@16.1.6)(react-dom@19.2.4)(react@19.2.4) - fumadocs-mdx: - specifier: 14.0.3 - version: 14.0.3(fumadocs-core@16.1.0)(next@16.1.6)(react@19.2.4)(vite@7.3.1) - fumadocs-ui: - specifier: 16.1.0 - version: 16.1.0(@types/react-dom@19.2.3)(@types/react@19.2.14)(next@16.1.6)(react-dom@19.2.4)(react@19.2.4)(tailwindcss@4.1.18) - lucide-static: - specifier: ^0.552.0 - version: 0.552.0 - next: - specifier: ^16.0.8 - version: 16.1.6(react-dom@19.2.4)(react@19.2.4) - react: - specifier: ^19.2.0 - version: 19.2.4 - react-dom: - specifier: ^19.2.0 - version: 19.2.4(react@19.2.4) - tailwind-merge: - specifier: ^3.3.1 - version: 3.4.0 - -devDependencies: - '@tailwindcss/postcss': - specifier: ^4.1.17 - version: 4.1.18 - '@tailwindcss/vite': - specifier: ^4.1.16 - version: 4.1.18(vite@7.3.1) - '@types/mdx': - specifier: ^2.0.13 - version: 2.0.13 - '@types/node': - specifier: ^24.10.0 - version: 24.10.13 - '@types/react': - specifier: ^19.2.2 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.2 - version: 19.2.3(@types/react@19.2.14) - tailwindcss: - specifier: ^4.1.16 - version: 4.1.18 - typescript: - specifier: ^5.9.3 - version: 5.9.3 +importers: + + .: + dependencies: + fumadocs-core: + specifier: 16.1.0 + version: 16.1.0(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + fumadocs-mdx: + specifier: 14.0.3 + version: 14.0.3(fumadocs-core@16.1.0(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)) + fumadocs-ui: + specifier: 16.1.0 + version: 16.1.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + lucide-static: + specifier: ^0.552.0 + version: 0.552.0 + next: + specifier: ^16.1.6 + version: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + tailwind-merge: + specifier: ^3.3.1 + version: 3.5.0 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.17 + version: 4.2.1 + '@tailwindcss/vite': + specifier: ^4.1.16 + version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/mdx': + specifier: ^2.0.13 + version: 2.0.13 + '@types/node': + specifier: ^24.10.0 + version: 24.12.0 + '@types/react': + specifier: ^19.2.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.14) + tailwindcss: + specifier: ^4.1.16 + version: 4.2.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 packages: - /@alloc/quick-lru@5.2.0: + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - dev: true - /@emnapi/runtime@1.8.1: - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - requiresBuild: true - dependencies: - tslib: 2.8.1 - dev: false - optional: true + '@emnapi/runtime@1.9.0': + resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==} - /@esbuild/aix-ppc64@0.27.3: - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - requiresBuild: true - optional: true - /@esbuild/android-arm64@0.27.3: - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - requiresBuild: true - optional: true - /@esbuild/android-arm@0.27.3: - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - requiresBuild: true - optional: true - /@esbuild/android-x64@0.27.3: - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - requiresBuild: true - optional: true - /@esbuild/darwin-arm64@0.27.3: - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - requiresBuild: true - optional: true - /@esbuild/darwin-x64@0.27.3: - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - requiresBuild: true - optional: true - /@esbuild/freebsd-arm64@0.27.3: - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - requiresBuild: true - optional: true - /@esbuild/freebsd-x64@0.27.3: - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - requiresBuild: true - optional: true - /@esbuild/linux-arm64@0.27.3: - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-arm@0.27.3: - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-ia32@0.27.3: - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-loong64@0.27.3: - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-mips64el@0.27.3: - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-ppc64@0.27.3: - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-riscv64@0.27.3: - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-s390x@0.27.3: - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - requiresBuild: true - optional: true - /@esbuild/linux-x64@0.27.3: - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - requiresBuild: true - optional: true - /@esbuild/netbsd-arm64@0.27.3: - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - requiresBuild: true - optional: true - /@esbuild/netbsd-x64@0.27.3: - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - requiresBuild: true - optional: true - /@esbuild/openbsd-arm64@0.27.3: - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - requiresBuild: true - optional: true - /@esbuild/openbsd-x64@0.27.3: - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - requiresBuild: true - optional: true - /@esbuild/openharmony-arm64@0.27.3: - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - requiresBuild: true - optional: true - /@esbuild/sunos-x64@0.27.3: - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - requiresBuild: true - optional: true - /@esbuild/win32-arm64@0.27.3: - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - requiresBuild: true - optional: true - /@esbuild/win32-ia32@0.27.3: - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - requiresBuild: true - optional: true - /@esbuild/win32-x64@0.27.3: - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] - requiresBuild: true - optional: true - /@floating-ui/core@1.7.4: - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - dependencies: - '@floating-ui/utils': 0.2.10 - dev: false + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - /@floating-ui/dom@1.7.5: - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 - dev: false + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - /@floating-ui/react-dom@2.1.7(react-dom@19.2.4)(react@19.2.4): - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - dependencies: - '@floating-ui/dom': 1.7.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@floating-ui/utils@0.2.10: - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - dev: false + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - /@formatjs/intl-localematcher@0.6.2: + '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} - dependencies: - tslib: 2.8.1 - dev: false - /@img/colour@1.0.0: - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - requiresBuild: true - dev: false - optional: true - /@img/sharp-darwin-arm64@0.34.5: + '@img/sharp-darwin-arm64@0.34.5': resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - dev: false - optional: true - /@img/sharp-darwin-x64@0.34.5: + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - dev: false - optional: true - /@img/sharp-libvips-darwin-arm64@1.2.4: + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: false - optional: true - /@img/sharp-libvips-darwin-x64@1.2.4: + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - requiresBuild: true - dev: false - optional: true - /@img/sharp-libvips-linux-arm64@1.2.4: + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@img/sharp-libvips-linux-arm@1.2.4: + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@img/sharp-libvips-linux-ppc64@1.2.4: + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@img/sharp-libvips-linux-riscv64@1.2.4: + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@img/sharp-libvips-linux-s390x@1.2.4: + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@img/sharp-libvips-linux-x64@1.2.4: + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@img/sharp-libvips-linuxmusl-arm64@1.2.4: + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [musl] - /@img/sharp-libvips-linuxmusl-x64@1.2.4: + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [musl] - /@img/sharp-linux-arm64@0.34.5: + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - dev: false - optional: true + libc: [glibc] - /@img/sharp-linux-arm@0.34.5: + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - dev: false - optional: true + libc: [glibc] - /@img/sharp-linux-ppc64@0.34.5: + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - dev: false - optional: true + libc: [glibc] - /@img/sharp-linux-riscv64@0.34.5: + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - dev: false - optional: true + libc: [glibc] - /@img/sharp-linux-s390x@0.34.5: + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - dev: false - optional: true + libc: [glibc] - /@img/sharp-linux-x64@0.34.5: + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - dev: false - optional: true + libc: [glibc] - /@img/sharp-linuxmusl-arm64@0.34.5: + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - dev: false - optional: true + libc: [musl] - /@img/sharp-linuxmusl-x64@0.34.5: + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - requiresBuild: true - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - dev: false - optional: true + libc: [musl] - /@img/sharp-wasm32@0.34.5: + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - requiresBuild: true - dependencies: - '@emnapi/runtime': 1.8.1 - dev: false - optional: true - /@img/sharp-win32-arm64@0.34.5: + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - requiresBuild: true - dev: false - optional: true - /@img/sharp-win32-ia32@0.34.5: + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - requiresBuild: true - dev: false - optional: true - /@img/sharp-win32-x64@0.34.5: + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] - requiresBuild: true - dev: false - optional: true - /@jridgewell/gen-mapping@0.3.13: + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - /@jridgewell/remapping@2.3.5: + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - dev: true - /@jridgewell/resolve-uri@3.1.2: + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - dev: true - /@jridgewell/sourcemap-codec@1.5.5: + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - dev: true - /@jridgewell/trace-mapping@0.3.31: + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - dev: true - /@mdx-js/mdx@3.1.1: + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - dependencies: - '@types/estree': 1.0.8 - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdx': 2.0.13 - acorn: 8.15.0 - collapse-white-space: 2.1.0 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - estree-util-scope: 1.0.0 - estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 - markdown-extensions: 2.0.0 - recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.15.0) - recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 - remark-rehype: 11.1.2 - source-map: 0.7.6 - unified: 11.0.5 - unist-util-position-from-estree: 2.0.0 - unist-util-stringify-position: 4.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - dev: false - /@next/env@16.1.6: - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} - dev: false + '@next/env@16.1.7': + resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==} - /@next/swc-darwin-arm64@16.1.6: - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + '@next/swc-darwin-arm64@16.1.7': + resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: false - optional: true - /@next/swc-darwin-x64@16.1.6: - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + '@next/swc-darwin-x64@16.1.7': + resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - requiresBuild: true - dev: false - optional: true - /@next/swc-linux-arm64-gnu@16.1.6: - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + '@next/swc-linux-arm64-gnu@16.1.7': + resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@next/swc-linux-arm64-musl@16.1.6: - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + '@next/swc-linux-arm64-musl@16.1.7': + resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [musl] - /@next/swc-linux-x64-gnu@16.1.6: - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + '@next/swc-linux-x64-gnu@16.1.7': + resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [glibc] - /@next/swc-linux-x64-musl@16.1.6: - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + '@next/swc-linux-x64-musl@16.1.7': + resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - requiresBuild: true - dev: false - optional: true + libc: [musl] - /@next/swc-win32-arm64-msvc@16.1.6: - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + '@next/swc-win32-arm64-msvc@16.1.7': + resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - requiresBuild: true - dev: false - optional: true - /@next/swc-win32-x64-msvc@16.1.6: - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + '@next/swc-win32-x64-msvc@16.1.7': + resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - requiresBuild: true - dev: false - optional: true - /@orama/orama@3.1.18: + '@orama/orama@3.1.18': resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} - dev: false - /@radix-ui/number@1.1.1: + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - dev: false - /@radix-ui/primitive@1.1.3: + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - dev: false - /@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-accordion@1.2.12': resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} peerDependencies: '@types/react': '*' @@ -710,23 +490,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: '@types/react': '*' @@ -738,15 +503,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-collapsible@1.1.12': resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} peerDependencies: '@types/react': '*' @@ -758,22 +516,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: '@types/react': '*' @@ -785,18 +529,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: '@types/react': '*' @@ -804,12 +538,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: '@types/react': '*' @@ -817,12 +547,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: '@types/react': '*' @@ -834,28 +560,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - dev: false - /@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: '@types/react': '*' @@ -863,12 +569,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: '@types/react': '*' @@ -880,19 +582,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-focus-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: '@types/react': '*' @@ -900,12 +591,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-focus-scope@1.1.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: '@types/react': '*' @@ -917,17 +604,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: '@types/react': '*' @@ -935,13 +613,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-navigation-menu@1.2.14': resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} peerDependencies: '@types/react': '*' @@ -953,28 +626,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: '@types/react': '*' @@ -986,29 +639,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) - dev: false - /@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: '@types/react': '*' @@ -1020,24 +652,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/rect': 1.1.1 - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-portal@1.1.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: '@types/react': '*' @@ -1049,16 +665,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-presence@1.1.5': resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: '@types/react': '*' @@ -1070,16 +678,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: '@types/react': '*' @@ -1091,15 +691,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: '@types/react': '*' @@ -1111,23 +704,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-scroll-area@1.2.10': resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} peerDependencies: '@types/react': '*' @@ -1139,23 +717,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: '@types/react': '*' @@ -1163,13 +726,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-slot@1.2.4': resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} peerDependencies: '@types/react': '*' @@ -1177,13 +735,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-tabs@1.1.13': resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} peerDependencies: '@types/react': '*' @@ -1195,22 +748,8 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: '@types/react': '*' @@ -1218,12 +757,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: '@types/react': '*' @@ -1231,14 +766,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: '@types/react': '*' @@ -1246,13 +775,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: '@types/react': '*' @@ -1260,13 +784,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: '@types/react': '*' @@ -1274,12 +793,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-previous@1.1.1': resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: '@types/react': '*' @@ -1287,12 +802,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: '@types/react': '*' @@ -1300,13 +811,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/rect': 1.1.1 - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4): + '@radix-ui/react-use-size@1.1.1': resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} peerDependencies: '@types/react': '*' @@ -1314,13 +820,8 @@ packages: peerDependenciesMeta: '@types/react': optional: true - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@types/react': 19.2.14 - react: 19.2.4 - dev: false - /@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4): + '@radix-ui/react-visually-hidden@1.2.3': resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: '@types/react': '*' @@ -1332,368 +833,246 @@ packages: optional: true '@types/react-dom': optional: true - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - dev: false - /@radix-ui/rect@1.1.1: + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - dev: false - /@rollup/rollup-android-arm-eabi@4.57.1: - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - requiresBuild: true - optional: true - /@rollup/rollup-android-arm64@4.57.1: - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - requiresBuild: true - optional: true - /@rollup/rollup-darwin-arm64@4.57.1: - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - requiresBuild: true - optional: true - /@rollup/rollup-darwin-x64@4.57.1: - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - requiresBuild: true - optional: true - /@rollup/rollup-freebsd-arm64@4.57.1: - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - requiresBuild: true - optional: true - /@rollup/rollup-freebsd-x64@4.57.1: - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - requiresBuild: true - optional: true - /@rollup/rollup-linux-arm-gnueabihf@4.57.1: - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-arm-musleabihf@4.57.1: - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - requiresBuild: true - optional: true + libc: [musl] - /@rollup/rollup-linux-arm64-gnu@4.57.1: - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-arm64-musl@4.57.1: - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - requiresBuild: true - optional: true + libc: [musl] - /@rollup/rollup-linux-loong64-gnu@4.57.1: - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-loong64-musl@4.57.1: - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - requiresBuild: true - optional: true + libc: [musl] - /@rollup/rollup-linux-ppc64-gnu@4.57.1: - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-ppc64-musl@4.57.1: - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - requiresBuild: true - optional: true + libc: [musl] - /@rollup/rollup-linux-riscv64-gnu@4.57.1: - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-riscv64-musl@4.57.1: - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - requiresBuild: true - optional: true + libc: [musl] - /@rollup/rollup-linux-s390x-gnu@4.57.1: - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-x64-gnu@4.57.1: - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - requiresBuild: true - optional: true + libc: [glibc] - /@rollup/rollup-linux-x64-musl@4.57.1: - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - requiresBuild: true - optional: true + libc: [musl] - /@rollup/rollup-openbsd-x64@4.57.1: - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} cpu: [x64] os: [openbsd] - requiresBuild: true - optional: true - /@rollup/rollup-openharmony-arm64@4.57.1: - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} cpu: [arm64] os: [openharmony] - requiresBuild: true - optional: true - /@rollup/rollup-win32-arm64-msvc@4.57.1: - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - requiresBuild: true - optional: true - /@rollup/rollup-win32-ia32-msvc@4.57.1: - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - requiresBuild: true - optional: true - /@rollup/rollup-win32-x64-gnu@4.57.1: - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} cpu: [x64] os: [win32] - requiresBuild: true - optional: true - /@rollup/rollup-win32-x64-msvc@4.57.1: - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] - requiresBuild: true - optional: true - /@shikijs/core@3.22.0: - resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} - dependencies: - '@shikijs/types': 3.22.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - dev: false + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} - /@shikijs/engine-javascript@3.22.0: - resolution: {integrity: sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw==} - dependencies: - '@shikijs/types': 3.22.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.4 - dev: false + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} - /@shikijs/engine-oniguruma@3.22.0: - resolution: {integrity: sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA==} - dependencies: - '@shikijs/types': 3.22.0 - '@shikijs/vscode-textmate': 10.0.2 - dev: false + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} - /@shikijs/langs@3.22.0: - resolution: {integrity: sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA==} - dependencies: - '@shikijs/types': 3.22.0 - dev: false + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} - /@shikijs/rehype@3.22.0: - resolution: {integrity: sha512-69b2VPc6XBy/VmAJlpBU5By+bJSBdE2nvgRCZXav7zujbrjXuT0F60DIrjKuutjPqNufuizE+E8tIZr2Yn8Z+g==} - dependencies: - '@shikijs/types': 3.22.0 - '@types/hast': 3.0.4 - hast-util-to-string: 3.0.1 - shiki: 3.22.0 - unified: 11.0.5 - unist-util-visit: 5.1.0 - dev: false + '@shikijs/rehype@3.23.0': + resolution: {integrity: sha512-GepKJxXHbXFfAkiZZZ+4V7x71Lw3s0ALYmydUxJRdvpKjSx9FOMSaunv6WRLFBXR6qjYerUq1YZQno+2gLEPwA==} - /@shikijs/themes@3.22.0: - resolution: {integrity: sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g==} - dependencies: - '@shikijs/types': 3.22.0 - dev: false + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} - /@shikijs/transformers@3.22.0: - resolution: {integrity: sha512-E7eRV7mwDBjueLF6852n2oYeJYxBq3NSsDk+uyruYAXONv4U8holGmIrT+mPRJQ1J1SNOH6L8G19KRzmBawrFw==} - dependencies: - '@shikijs/core': 3.22.0 - '@shikijs/types': 3.22.0 - dev: false + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} - /@shikijs/types@3.22.0: - resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==} - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - dev: false + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} - /@shikijs/vscode-textmate@10.0.2: + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - dev: false - /@standard-schema/spec@1.1.0: + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - dev: false - /@swc/helpers@0.5.15: + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - dependencies: - tslib: 2.8.1 - dev: false - /@tailwindcss/node@4.1.18: - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.30.2 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.18 - dev: true + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - /@tailwindcss/oxide-android-arm64@4.1.18: - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide-darwin-arm64@4.1.18: - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide-darwin-x64@4.1.18: - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide-freebsd-x64@4.1.18: - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18: - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide-linux-arm64-gnu@4.1.18: - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - requiresBuild: true - dev: true - optional: true + libc: [glibc] - /@tailwindcss/oxide-linux-arm64-musl@4.1.18: - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] - requiresBuild: true - dev: true - optional: true + libc: [musl] - /@tailwindcss/oxide-linux-x64-gnu@4.1.18: - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - requiresBuild: true - dev: true - optional: true + libc: [glibc] - /@tailwindcss/oxide-linux-x64-musl@4.1.18: - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] - requiresBuild: true - dev: true - optional: true + libc: [musl] - /@tailwindcss/oxide-wasm32-wasi@4.1.18: - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] - requiresBuild: true - dev: true - optional: true bundledDependencies: - '@napi-rs/wasm-runtime' - '@emnapi/core' @@ -1702,233 +1081,150 @@ packages: - '@emnapi/wasi-threads' - tslib - /@tailwindcss/oxide-win32-arm64-msvc@4.1.18: - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide-win32-x64-msvc@4.1.18: - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@tailwindcss/oxide@4.1.18: - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - dev: true - - /@tailwindcss/postcss@4.1.18: - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} - dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - postcss: 8.5.6 - tailwindcss: 4.1.18 - dev: true - - /@tailwindcss/vite@4.1.18(vite@7.3.1): - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.1': + resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 - dependencies: - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@24.10.13) - dev: true - /@types/debug@4.1.12: + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - dependencies: - '@types/ms': 2.1.0 - dev: false - /@types/estree-jsx@1.0.5: + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - dependencies: - '@types/estree': 1.0.8 - dev: false - /@types/estree@1.0.8: + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - /@types/hast@3.0.4: + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - dependencies: - '@types/unist': 3.0.3 - dev: false - /@types/mdast@4.0.4: + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - dependencies: - '@types/unist': 3.0.3 - dev: false - /@types/mdx@2.0.13: + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - /@types/ms@2.1.0: + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - dev: false - /@types/node@24.10.13: - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - dependencies: - undici-types: 7.16.0 + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - /@types/react-dom@19.2.3(@types/react@19.2.14): + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 - dependencies: - '@types/react': 19.2.14 - /@types/react@19.2.14: + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - dependencies: - csstype: 3.2.3 - /@types/unist@2.0.11: + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - dev: false - /@types/unist@3.0.3: + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - dev: false - /@ungap/structured-clone@1.3.0: + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - dev: false - /acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.15.0 - dev: false - /acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true - dev: false - /argparse@2.0.1: + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: false - /aria-hidden@1.2.6: + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - dependencies: - tslib: 2.8.1 - dev: false - /astring@1.9.0: + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true - dev: false - /bail@2.0.2: + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - dev: false - /baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.10.8: + resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==} + engines: {node: '>=6.0.0'} hasBin: true - dev: false - /caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - dev: false + caniuse-lite@1.0.30001779: + resolution: {integrity: sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==} - /ccount@2.0.1: + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - dev: false - /character-entities-html4@2.1.0: + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - dev: false - /character-entities-legacy@3.0.0: + character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - dev: false - /character-entities@2.0.2: + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - dev: false - /character-reference-invalid@2.0.1: + character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - dev: false - /chokidar@4.0.3: + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - dependencies: - readdirp: 4.1.2 - dev: false - /class-variance-authority@0.7.1: + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - dependencies: - clsx: 2.1.1 - dev: false - /client-only@0.0.1: + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - dev: false - /clsx@2.1.1: + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - dev: false - /collapse-white-space@2.1.0: + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} - dev: false - /comma-separated-tokens@2.0.3: + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - dev: false - /compute-scroll-into-view@3.1.1: + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - dev: false - /cssesc@3.0.0: + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - dev: false - /csstype@3.2.3: + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - /debug@4.4.3: + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: @@ -1936,157 +1232,71 @@ packages: peerDependenciesMeta: supports-color: optional: true - dependencies: - ms: 2.1.3 - dev: false - /decode-named-character-reference@1.3.0: + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - dependencies: - character-entities: 2.0.2 - dev: false - /dequal@2.0.3: + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - dev: false - /detect-libc@2.1.2: + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - /detect-node-es@1.1.0: + detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - dev: false - /devlop@1.1.0: + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dependencies: - dequal: 2.0.3 - dev: false - /enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - dev: true - /esast-util-from-estree@2.0.0: + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} - dependencies: - '@types/estree-jsx': 1.0.5 - devlop: 1.1.0 - estree-util-visit: 2.0.0 - unist-util-position-from-estree: 2.0.0 - dev: false - /esast-util-from-js@2.0.1: + esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - dependencies: - '@types/estree-jsx': 1.0.5 - acorn: 8.15.0 - esast-util-from-estree: 2.0.0 - vfile-message: 4.0.3 - dev: false - /esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - /escape-string-regexp@5.0.0: + + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - dev: false - /estree-util-attach-comments@3.0.0: + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} - dependencies: - '@types/estree': 1.0.8 - dev: false - /estree-util-build-jsx@3.0.1: + estree-util-build-jsx@3.0.1: resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} - dependencies: - '@types/estree-jsx': 1.0.5 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - estree-walker: 3.0.3 - dev: false - /estree-util-is-identifier-name@3.0.0: + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - dev: false - /estree-util-scope@1.0.0: + estree-util-scope@1.0.0: resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} - dependencies: - '@types/estree': 1.0.8 - devlop: 1.1.0 - dev: false - /estree-util-to-js@2.0.0: + estree-util-to-js@2.0.0: resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} - dependencies: - '@types/estree-jsx': 1.0.5 - astring: 1.9.0 - source-map: 0.7.6 - dev: false - /estree-util-value-to-estree@3.5.0: + estree-util-value-to-estree@3.5.0: resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} - dependencies: - '@types/estree': 1.0.8 - dev: false - /estree-util-visit@2.0.0: + estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/unist': 3.0.3 - dev: false - /estree-walker@3.0.3: + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - dependencies: - '@types/estree': 1.0.8 - dev: false - /extend@3.0.2: + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - dev: false - /fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: @@ -2094,17 +1304,13 @@ packages: peerDependenciesMeta: picomatch: optional: true - dependencies: - picomatch: 4.0.3 - /fsevents@2.3.3: + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - requiresBuild: true - optional: true - /fumadocs-core@16.1.0(@types/react@19.2.14)(next@16.1.6)(react-dom@19.2.4)(react@19.2.4): + fumadocs-core@16.1.0: resolution: {integrity: sha512-5pbO2bOGc/xlb2yLQSy6Oag8mvD5CNf5HzQIG80HjZzLXYWEOHW8yovRKnWKRF9gAibn6WHnbssj3YPAlitV/A==} peerDependencies: '@mixedbread/sdk': ^0.19.0 @@ -2141,117 +1347,1842 @@ packages: optional: true waku: optional: true - dependencies: - '@formatjs/intl-localematcher': 0.6.2 - '@orama/orama': 3.1.18 - '@shikijs/rehype': 3.22.0 - '@shikijs/transformers': 3.22.0 - '@types/react': 19.2.14 - estree-util-value-to-estree: 3.5.0 - github-slugger: 2.0.0 - hast-util-to-estree: 3.1.3 - hast-util-to-jsx-runtime: 2.3.6 - image-size: 2.0.2 - negotiator: 1.0.0 - next: 16.1.6(react-dom@19.2.4)(react@19.2.4) - npm-to-yarn: 3.0.1 - path-to-regexp: 8.3.0 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - remark: 15.0.1 - remark-gfm: 4.0.1 + + fumadocs-mdx@14.0.3: + resolution: {integrity: sha512-GnRnnkb9QUiur9fbHF5aPsD+IkxLwxJLNC/C1L9W+Ii/o8RseRVIWUJpEZNlG62KPtwsbwkr/0mQ4ToHUzCRng==} + hasBin: true + peerDependencies: + '@fumadocs/mdx-remote': ^1.4.0 + fumadocs-core: ^15.0.0 || ^16.0.0 + next: ^15.3.0 || ^16.0.0 + react: '*' + vite: 6.x.x || 7.x.x + peerDependenciesMeta: + '@fumadocs/mdx-remote': + optional: true + next: + optional: true + react: + optional: true + vite: + optional: true + + fumadocs-ui@16.1.0: + resolution: {integrity: sha512-Yty9tINshfQQYHE/K+nH7+7VHTNtWL0hgNvwI7lLd6xcJrbFUgzJQUeS0oGxZx+7rgqGYvNqlByKLRQHqPR9dw==} + peerDependencies: + '@types/react': '*' + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + tailwindcss: ^4.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + next: + optional: true + tailwindcss: + optional: true + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + + lucide-static@0.552.0: + resolution: {integrity: sha512-wL+9EAx8k/4i/w9qGuAwHrrV8SryVsJk+Ug9SErtg18Zq95FMwMXlu4Dhl4RS8C2b1oxFu66p7YK96yrNsm6cg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.1.7: + resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + npm-to-yarn@3.0.1: + resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-medium-image-zoom@5.4.1: + resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@emnapi/runtime@1.9.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.16.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.16.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@next/env@16.1.7': {} + + '@next/swc-darwin-arm64@16.1.7': + optional: true + + '@next/swc-darwin-x64@16.1.7': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.7': + optional: true + + '@next/swc-linux-arm64-musl@16.1.7': + optional: true + + '@next/swc-linux-x64-gnu@16.1.7': + optional: true + + '@next/swc-linux-x64-musl@16.1.7': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.7': + optional: true + + '@next/swc-win32-x64-msvc@16.1.7': + optional: true + + '@orama/orama@3.1.18': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/rehype@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@types/hast': 3.0.4 + hast-util-to-string: 3.0.1 + shiki: 3.23.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/postcss@4.2.1': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + postcss: 8.5.8 + tailwindcss: 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1) + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/ms@2.1.0': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + astring@1.9.0: {} + + bail@2.0.2: {} + + baseline-browser-mapping@2.10.8: {} + + caniuse-lite@1.0.30001779: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + comma-separated-tokens@2.0.3: {} + + compute-scroll-into-view@3.1.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.16.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escape-string-regexp@5.0.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.8 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@3.5.0: + dependencies: + '@types/estree': 1.0.8 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + extend@3.0.2: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + fumadocs-core@16.1.0(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@formatjs/intl-localematcher': 0.6.2 + '@orama/orama': 3.1.18 + '@shikijs/rehype': 3.23.0 + '@shikijs/transformers': 3.23.0 + estree-util-value-to-estree: 3.5.0 + github-slugger: 2.0.0 + hast-util-to-estree: 3.1.3 + hast-util-to-jsx-runtime: 2.3.6 + image-size: 2.0.2 + negotiator: 1.0.0 + npm-to-yarn: 3.0.1 + path-to-regexp: 8.3.0 + remark: 15.0.1 + remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 3.22.0 + shiki: 3.23.0 unist-util-visit: 5.1.0 + optionalDependencies: + '@types/react': 19.2.14 + next: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) transitivePeerDependencies: - supports-color - dev: false - /fumadocs-mdx@14.0.3(fumadocs-core@16.1.0)(next@16.1.6)(react@19.2.4)(vite@7.3.1): - resolution: {integrity: sha512-GnRnnkb9QUiur9fbHF5aPsD+IkxLwxJLNC/C1L9W+Ii/o8RseRVIWUJpEZNlG62KPtwsbwkr/0mQ4ToHUzCRng==} - hasBin: true - peerDependencies: - '@fumadocs/mdx-remote': ^1.4.0 - fumadocs-core: ^15.0.0 || ^16.0.0 - next: ^15.3.0 || ^16.0.0 - react: '*' - vite: 6.x.x || 7.x.x - peerDependenciesMeta: - '@fumadocs/mdx-remote': - optional: true - next: - optional: true - react: - optional: true - vite: - optional: true + fumadocs-mdx@14.0.3(fumadocs-core@16.1.0(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 4.0.3 - esbuild: 0.27.3 + esbuild: 0.27.4 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.1.0(@types/react@19.2.14)(next@16.1.6)(react-dom@19.2.4)(react@19.2.4) + fumadocs-core: 16.1.0(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) js-yaml: 4.1.1 - lru-cache: 11.2.6 + lru-cache: 11.2.7 mdast-util-to-markdown: 2.1.2 - next: 16.1.6(react-dom@19.2.4)(react@19.2.4) picocolors: 1.1.1 picomatch: 4.0.3 - react: 19.2.4 remark-mdx: 3.1.1 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 unified: 11.0.5 unist-util-remove-position: 5.0.0 unist-util-visit: 5.1.0 vfile: 6.0.3 - vite: 7.3.1(@types/node@24.10.13) zod: 4.3.6 + optionalDependencies: + next: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color - dev: false - /fumadocs-ui@16.1.0(@types/react-dom@19.2.3)(@types/react@19.2.14)(next@16.1.6)(react-dom@19.2.4)(react@19.2.4)(tailwindcss@4.1.18): - resolution: {integrity: sha512-Yty9tINshfQQYHE/K+nH7+7VHTNtWL0hgNvwI7lLd6xcJrbFUgzJQUeS0oGxZx+7rgqGYvNqlByKLRQHqPR9dw==} - peerDependencies: - '@types/react': '*' - next: 16.x.x - react: ^19.2.0 - react-dom: ^19.2.0 - tailwindcss: ^4.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - next: - optional: true - tailwindcss: - optional: true + fumadocs-ui@16.1.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): dependencies: - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) - '@types/react': 19.2.14 + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 16.1.0(@types/react@19.2.14)(next@16.1.6)(react-dom@19.2.4)(react@19.2.4) + fumadocs-core: 16.1.0(@types/react@19.2.14)(next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) lodash.merge: 4.6.2 - next: 16.1.6(react-dom@19.2.4)(react@19.2.4) - next-themes: 0.4.6(react-dom@19.2.4)(react@19.2.4) + next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) postcss-selector-parser: 7.1.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-medium-image-zoom: 5.4.0(react-dom@19.2.4)(react@19.2.4) + react-medium-image-zoom: 5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) scroll-into-view-if-needed: 3.1.0 - tailwind-merge: 3.4.0 - tailwindcss: 4.1.18 + tailwind-merge: 3.5.0 + optionalDependencies: + '@types/react': 19.2.14 + next: 16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tailwindcss: 4.2.1 transitivePeerDependencies: - '@mixedbread/sdk' - '@orama/core' @@ -2262,23 +3193,14 @@ packages: - react-router - supports-color - waku - dev: false - /get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - dev: false + get-nonce@1.0.1: {} - /github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - dev: false + github-slugger@2.0.0: {} - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true + graceful-fs@4.2.11: {} - /hast-util-to-estree@3.1.3: - resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -2298,10 +3220,8 @@ packages: zwitch: 2.0.4 transitivePeerDependencies: - supports-color - dev: false - /hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 '@types/unist': 3.0.3 @@ -2314,10 +3234,8 @@ packages: space-separated-tokens: 2.0.2 stringify-entities: 4.0.4 zwitch: 2.0.4 - dev: false - /hast-util-to-jsx-runtime@2.3.6: - resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -2336,231 +3254,113 @@ packages: vfile-message: 4.0.3 transitivePeerDependencies: - supports-color - dev: false - /hast-util-to-string@3.0.1: - resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-string@3.0.1: dependencies: '@types/hast': 3.0.4 - dev: false - /hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 - dev: false - /html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - dev: false + html-void-elements@3.0.0: {} - /image-size@2.0.2: - resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} - engines: {node: '>=16.x'} - hasBin: true - dev: false + image-size@2.0.2: {} - /inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - dev: false + inline-style-parser@0.2.7: {} - /is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - dev: false + is-alphabetical@2.0.1: {} - /is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-alphanumerical@2.0.1: dependencies: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - dev: false - /is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - dev: false + is-decimal@2.0.1: {} - /is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - dev: false + is-hexadecimal@2.0.1: {} - /is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - dev: false + is-plain-obj@4.1.0: {} - /jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - dev: true + jiti@2.6.1: {} - /js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true + js-yaml@4.1.1: dependencies: argparse: 2.0.1 - dev: false - /lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true + lightningcss-android-arm64@1.31.1: optional: true - /lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true + lightningcss-darwin-arm64@1.31.1: optional: true - /lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true + lightningcss-darwin-x64@1.31.1: optional: true - /lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true + lightningcss-freebsd-x64@1.31.1: optional: true - /lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - /lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true + lightningcss-linux-arm64-gnu@1.31.1: optional: true - /lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true + lightningcss-linux-arm64-musl@1.31.1: optional: true - /lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true + lightningcss-linux-x64-gnu@1.31.1: optional: true - /lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true + lightningcss-linux-x64-musl@1.31.1: optional: true - /lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true + lightningcss-win32-arm64-msvc@1.31.1: optional: true - /lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true + lightningcss-win32-x64-msvc@1.31.1: optional: true - /lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} - engines: {node: '>= 12.0.0'} + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - dev: true - - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: false + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 - /longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - dev: false + lodash.merge@4.6.2: {} - /lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - dev: false + longest-streak@3.1.0: {} - /lucide-static@0.552.0: - resolution: {integrity: sha512-wL+9EAx8k/4i/w9qGuAwHrrV8SryVsJk+Ug9SErtg18Zq95FMwMXlu4Dhl4RS8C2b1oxFu66p7YK96yrNsm6cg==} - dev: false + lru-cache@11.2.7: {} - /magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + lucide-static@0.552.0: {} + + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - dev: true - /markdown-extensions@2.0.0: - resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} - engines: {node: '>=16'} - dev: false + markdown-extensions@2.0.0: {} - /markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - dev: false + markdown-table@3.0.4: {} - /mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 escape-string-regexp: 5.0.0 unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - dev: false - /mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 @@ -2576,67 +3376,55 @@ packages: unist-util-stringify-position: 4.0.0 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + mdast-util-gfm-autolink-literal@2.0.1: dependencies: '@types/mdast': 4.0.4 ccount: 2.0.1 devlop: 1.1.0 mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - dev: false - /mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 @@ -2645,23 +3433,19 @@ packages: mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-mdx-jsx@3.2.0: - resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -2669,7 +3453,7 @@ packages: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -2677,42 +3461,34 @@ packages: vfile-message: 4.0.3 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-mdx@3.0.0: - resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-mdx-expression: 2.0.1 mdast-util-mdx-jsx: 3.2.0 mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - dev: false - /mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 - dev: false - /mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -2723,10 +3499,8 @@ packages: unist-util-position: 5.0.0 unist-util-visit: 5.1.0 vfile: 6.0.3 - dev: false - /mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 @@ -2737,16 +3511,12 @@ packages: micromark-util-decode-string: 2.0.1 unist-util-visit: 5.1.0 zwitch: 2.0.4 - dev: false - /mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdast-util-to-string@4.0.0: dependencies: '@types/mdast': 4.0.4 - dev: false - /micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 devlop: 1.1.0 @@ -2764,19 +3534,15 @@ packages: micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + micromark-extension-gfm-footnote@2.1.0: dependencies: devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -2786,10 +3552,8 @@ packages: micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + micromark-extension-gfm-strikethrough@2.1.0: dependencies: devlop: 1.1.0 micromark-util-chunked: 2.0.1 @@ -2797,36 +3561,28 @@ packages: micromark-util-resolve-all: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm-table@2.1.1: - resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + micromark-extension-gfm-table@2.1.1: dependencies: devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + micromark-extension-gfm-tagfilter@2.0.0: dependencies: micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + micromark-extension-gfm-task-list-item@2.1.0: dependencies: devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-gfm@3.0.0: dependencies: micromark-extension-gfm-autolink-literal: 2.1.0 micromark-extension-gfm-footnote: 2.1.0 @@ -2836,10 +3592,8 @@ packages: micromark-extension-gfm-task-list-item: 2.1.0 micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-mdx-expression@3.0.1: - resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.8 devlop: 1.1.0 @@ -2849,10 +3603,8 @@ packages: micromark-util-events-to-acorn: 2.0.3 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-extension-mdx-jsx@3.0.2: - resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + micromark-extension-mdx-jsx@3.0.2: dependencies: '@types/estree': 1.0.8 devlop: 1.1.0 @@ -2864,16 +3616,12 @@ packages: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 vfile-message: 4.0.3 - dev: false - /micromark-extension-mdx-md@2.0.0: - resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + micromark-extension-mdx-md@2.0.0: dependencies: micromark-util-types: 2.0.2 - dev: false - /micromark-extension-mdxjs-esm@3.0.0: - resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + micromark-extension-mdxjs-esm@3.0.0: dependencies: '@types/estree': 1.0.8 devlop: 1.1.0 @@ -2884,40 +3632,32 @@ packages: micromark-util-types: 2.0.2 unist-util-position-from-estree: 2.0.0 vfile-message: 4.0.3 - dev: false - /micromark-extension-mdxjs@3.0.0: - resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 micromark-extension-mdxjs-esm: 3.0.0 micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-factory-mdx-expression@2.0.3: - resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + micromark-factory-mdx-expression@2.0.3: dependencies: '@types/estree': 1.0.8 devlop: 1.1.0 @@ -2928,82 +3668,60 @@ packages: micromark-util-types: 2.0.2 unist-util-position-from-estree: 2.0.0 vfile-message: 4.0.3 - dev: false - /micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-types: 2.0.2 - dev: false - /micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + micromark-factory-title@2.0.1: dependencies: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + micromark-factory-whitespace@2.0.1: dependencies: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + micromark-util-chunked@2.0.1: dependencies: micromark-util-symbol: 2.0.1 - dev: false - /micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + micromark-util-classify-character@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + micromark-util-combine-extensions@2.0.1: dependencies: micromark-util-chunked: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + micromark-util-decode-numeric-character-reference@2.0.2: dependencies: micromark-util-symbol: 2.0.1 - dev: false - /micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + micromark-util-decode-string@2.0.1: dependencies: decode-named-character-reference: 1.3.0 micromark-util-character: 2.1.1 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 - dev: false - /micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - dev: false + micromark-util-encode@2.0.1: {} - /micromark-util-events-to-acorn@2.0.3: - resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + micromark-util-events-to-acorn@2.0.3: dependencies: '@types/estree': 1.0.8 '@types/unist': 3.0.3 @@ -3012,51 +3730,35 @@ packages: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 vfile-message: 4.0.3 - dev: false - /micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - dev: false + micromark-util-html-tag-name@2.0.1: {} - /micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + micromark-util-normalize-identifier@2.0.1: dependencies: micromark-util-symbol: 2.0.1 - dev: false - /micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + micromark-util-resolve-all@2.0.1: dependencies: micromark-util-types: 2.0.2 - dev: false - /micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + micromark-util-sanitize-uri@2.0.1: dependencies: micromark-util-character: 2.1.1 micromark-util-encode: 2.0.1 micromark-util-symbol: 2.0.1 - dev: false - /micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + micromark-util-subtokenize@2.1.0: dependencies: devlop: 1.1.0 micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - dev: false - /micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - dev: false + micromark-util-symbol@2.0.1: {} - /micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - dev: false + micromark-util-types@2.0.2: {} - /micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromark@4.0.2: dependencies: '@types/debug': 4.1.12 debug: 4.4.3 @@ -3077,95 +3779,53 @@ packages: micromark-util-types: 2.0.2 transitivePeerDependencies: - supports-color - dev: false - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: false + ms@2.1.3: {} - /nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true + nanoid@3.3.11: {} - /negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - dev: false + negotiator@1.0.0: {} - /next-themes@0.4.6(react-dom@19.2.4)(react@19.2.4): - resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} - peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - dev: false - /next@16.1.6(react-dom@19.2.4)(react@19.2.4): - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true + next@16.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.1.6 + '@next/env': 16.1.7 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + baseline-browser-mapping: 2.10.8 + caniuse-lite: 1.0.30001779 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 + '@next/swc-darwin-arm64': 16.1.7 + '@next/swc-darwin-x64': 16.1.7 + '@next/swc-linux-arm64-gnu': 16.1.7 + '@next/swc-linux-arm64-musl': 16.1.7 + '@next/swc-linux-x64-gnu': 16.1.7 + '@next/swc-linux-x64-musl': 16.1.7 + '@next/swc-win32-arm64-msvc': 16.1.7 + '@next/swc-win32-x64-msvc': 16.1.7 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - dev: false - /npm-to-yarn@3.0.1: - resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: false + npm-to-yarn@3.0.1: {} - /oniguruma-parser@0.12.1: - resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - dev: false + oniguruma-parser@0.12.1: {} - /oniguruma-to-es@4.3.4: - resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} + oniguruma-to-es@4.3.5: dependencies: oniguruma-parser: 0.12.1 regex: 6.1.0 regex-recursion: 6.0.2 - dev: false - /parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 character-entities-legacy: 3.0.0 @@ -3174,195 +3834,121 @@ packages: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - dev: false - /path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} - dev: false + path-to-regexp@8.3.0: {} - /picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picocolors@1.1.1: {} - /picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} + picomatch@4.0.3: {} - /postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} - engines: {node: '>=4'} + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - dev: false - /postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} + postcss@8.4.31: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - dev: false - /postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - /property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - dev: false + property-information@7.1.0: {} - /react-dom@19.2.4(react@19.2.4): - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 scheduler: 0.27.0 - dev: false - /react-medium-image-zoom@5.4.0(react-dom@19.2.4)(react@19.2.4): - resolution: {integrity: sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - dev: false - /react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): dependencies: - '@types/react': 19.2.14 react: 19.2.4 react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) tslib: 2.8.1 - dev: false + optionalDependencies: + '@types/react': 19.2.14 - /react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): dependencies: - '@types/react': 19.2.14 react: 19.2.4 react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) tslib: 2.8.1 use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) - dev: false + optionalDependencies: + '@types/react': 19.2.14 - /react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): dependencies: - '@types/react': 19.2.14 get-nonce: 1.0.1 react: 19.2.4 tslib: 2.8.1 - dev: false + optionalDependencies: + '@types/react': 19.2.14 - /react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - dev: false + react@19.2.4: {} - /readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - dev: false + readdirp@4.1.2: {} - /recma-build-jsx@1.0.0: - resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - dev: false - /recma-jsx@1.0.1(acorn@8.15.0): - resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + recma-jsx@1.0.1(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 unified: 11.0.5 - dev: false - /recma-parse@1.0.0: - resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + recma-parse@1.0.0: dependencies: '@types/estree': 1.0.8 esast-util-from-js: 2.0.1 unified: 11.0.5 vfile: 6.0.3 - dev: false - /recma-stringify@1.0.0: - resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + recma-stringify@1.0.0: dependencies: '@types/estree': 1.0.8 estree-util-to-js: 2.0.0 unified: 11.0.5 vfile: 6.0.3 - dev: false - /regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 - dev: false - /regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - dev: false + regex-utilities@2.3.0: {} - /regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regex@6.1.0: dependencies: regex-utilities: 2.3.0 - dev: false - /rehype-recma@1.0.0: - resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color - dev: false - /remark-gfm@4.0.1: - resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 mdast-util-gfm: 3.1.0 @@ -3372,48 +3958,38 @@ packages: unified: 11.0.5 transitivePeerDependencies: - supports-color - dev: false - /remark-mdx@3.1.1: - resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + remark-mdx@3.1.1: dependencies: mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - dev: false - /remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: - supports-color - dev: false - /remark-rehype@11.1.2: - resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-rehype@11.1.2: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 - dev: false - /remark-stringify@11.0.0: - resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - dev: false - /remark@15.0.1: - resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + remark@15.0.1: dependencies: '@types/mdast': 4.0.4 remark-parse: 11.0.0 @@ -3421,66 +3997,50 @@ packages: unified: 11.0.5 transitivePeerDependencies: - supports-color - dev: false - /rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - /scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - dev: false + scheduler@0.27.0: {} - /scroll-into-view-if-needed@3.1.0: - resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 - dev: false - /semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - requiresBuild: true - dev: false + semver@7.7.4: optional: true - /sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - requiresBuild: true + sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.7.4 optionalDependencies: @@ -3508,118 +4068,67 @@ packages: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - dev: false optional: true - /shiki@3.22.0: - resolution: {integrity: sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g==} + shiki@3.23.0: dependencies: - '@shikijs/core': 3.22.0 - '@shikijs/engine-javascript': 3.22.0 - '@shikijs/engine-oniguruma': 3.22.0 - '@shikijs/langs': 3.22.0 - '@shikijs/themes': 3.22.0 - '@shikijs/types': 3.22.0 + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - dev: false - /source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} + source-map-js@1.2.1: {} - /source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - dev: false + source-map@0.7.6: {} - /space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - dev: false + space-separated-tokens@2.0.2: {} - /stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 - dev: false - /style-to-js@1.1.21: - resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 - dev: false - /style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + style-to-object@1.0.14: dependencies: inline-style-parser: 0.2.7 - dev: false - /styled-jsx@5.1.6(react@19.2.4): - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true + styled-jsx@5.1.6(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 - dev: false - /tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} - dev: false + tailwind-merge@3.5.0: {} - /tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.2.1: {} - /tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - dev: true + tapable@2.3.0: {} - /tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} - engines: {node: '>=18'} - dev: false + tinyexec@1.0.4: {} - /tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - /trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - dev: false + trim-lines@3.0.1: {} - /trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - dev: false + trough@2.2.0: {} - /tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - dev: false + tslib@2.8.1: {} - /typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - dev: true + typescript@5.9.3: {} - /undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.16.0: {} - /unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 bail: 2.0.2 @@ -3628,157 +4137,80 @@ packages: is-plain-obj: 4.1.0 trough: 2.2.0 vfile: 6.0.3 - dev: false - /unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 - dev: false - /unist-util-position-from-estree@2.0.0: - resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position-from-estree@2.0.0: dependencies: '@types/unist': 3.0.3 - dev: false - /unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 - dev: false - /unist-util-remove-position@5.0.0: - resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-remove-position@5.0.0: dependencies: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 - dev: false - /unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 - dev: false - /unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 - dev: false - /unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unist-util-visit@5.1.0: dependencies: '@types/unist': 3.0.3 unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - dev: false - /use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): dependencies: - '@types/react': 19.2.14 react: 19.2.4 tslib: 2.8.1 - dev: false + optionalDependencies: + '@types/react': 19.2.14 - /use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): dependencies: - '@types/react': 19.2.14 detect-node-es: 1.1.0 react: 19.2.4 tslib: 2.8.1 - dev: false + optionalDependencies: + '@types/react': 19.2.14 - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: false + util-deprecate@1.0.2: {} - /vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 - dev: false - /vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vfile@6.0.3: dependencies: '@types/unist': 3.0.3 vfile-message: 4.0.3 - dev: false - /vite@7.3.1(@types/node@24.10.13): - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true + vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: - '@types/node': 24.10.13 - esbuild: 0.27.3 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.57.1 + postcss: 8.5.8 + rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 24.12.0 fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 - /zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - dev: false + zod@4.3.6: {} - /zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - dev: false + zwitch@2.0.4: {} diff --git a/docs/public/media/azure-logo.svg b/docs/public/media/azure-logo.svg new file mode 100644 index 0000000000..ff5dfa5c11 --- /dev/null +++ b/docs/public/media/azure-logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/public/media/docker-logo.svg b/docs/public/media/docker-logo.svg new file mode 100644 index 0000000000..09a5a664af --- /dev/null +++ b/docs/public/media/docker-logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/public/media/logo-aws.png b/docs/public/media/logo-aws.png new file mode 100644 index 0000000000..bfaf690520 Binary files /dev/null and b/docs/public/media/logo-aws.png differ diff --git a/docs/public/media/logo-hetzner.png b/docs/public/media/logo-hetzner.png new file mode 100644 index 0000000000..707e4097c3 Binary files /dev/null and b/docs/public/media/logo-hetzner.png differ diff --git a/docs/tsconfig.tsbuildinfo b/docs/tsconfig.tsbuildinfo deleted file mode 100644 index bd62cb9664..0000000000 --- a/docs/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/global.d.ts","./node_modules/.pnpm/csstype@3.2.3/node_modules/csstype/index.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/index.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/jsx-runtime.d.ts","./node_modules/.pnpm/@types+unist@3.0.3/node_modules/@types/unist/index.d.ts","./node_modules/.pnpm/@types+mdast@4.0.4/node_modules/@types/mdast/index.d.ts","./node_modules/.pnpm/@types+estree@1.0.8/node_modules/@types/estree/index.d.ts","./node_modules/.pnpm/@types+estree-jsx@1.0.5/node_modules/@types/estree-jsx/index.d.ts","./node_modules/.pnpm/vfile-message@4.0.3/node_modules/vfile-message/lib/index.d.ts","./node_modules/.pnpm/vfile-message@4.0.3/node_modules/vfile-message/index.d.ts","./node_modules/.pnpm/vfile@6.0.3/node_modules/vfile/lib/index.d.ts","./node_modules/.pnpm/vfile@6.0.3/node_modules/vfile/index.d.ts","./node_modules/.pnpm/unified@11.0.5/node_modules/unified/lib/callable-instance.d.ts","./node_modules/.pnpm/trough@2.2.0/node_modules/trough/lib/index.d.ts","./node_modules/.pnpm/trough@2.2.0/node_modules/trough/index.d.ts","./node_modules/.pnpm/unified@11.0.5/node_modules/unified/lib/index.d.ts","./node_modules/.pnpm/unified@11.0.5/node_modules/unified/index.d.ts","./node_modules/.pnpm/source-map@0.7.6/node_modules/source-map/source-map.d.ts","./node_modules/.pnpm/@types+hast@3.0.4/node_modules/@types/hast/index.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/comment.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/element.d.ts","./node_modules/.pnpm/micromark-util-types@2.0.2/node_modules/micromark-util-types/index.d.ts","./node_modules/.pnpm/mdast-util-from-markdown@2.0.2/node_modules/mdast-util-from-markdown/lib/types.d.ts","./node_modules/.pnpm/mdast-util-from-markdown@2.0.2/node_modules/mdast-util-from-markdown/lib/index.d.ts","./node_modules/.pnpm/mdast-util-from-markdown@2.0.2/node_modules/mdast-util-from-markdown/index.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/types.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/index.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/blockquote.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/break.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/code.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/definition.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/emphasis.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/heading.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/html.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/image.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/image-reference.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/inline-code.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/link.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/link-reference.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/list.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/list-item.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/paragraph.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/root.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/strong.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/text.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/thematic-break.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/lib/handle/index.d.ts","./node_modules/.pnpm/mdast-util-to-markdown@2.1.2/node_modules/mdast-util-to-markdown/index.d.ts","./node_modules/.pnpm/mdast-util-mdx-expression@2.0.1/node_modules/mdast-util-mdx-expression/lib/index.d.ts","./node_modules/.pnpm/mdast-util-mdx-expression@2.0.1/node_modules/mdast-util-mdx-expression/index.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/mdx-expression.d.ts","./node_modules/.pnpm/mdast-util-mdx-jsx@3.2.0/node_modules/mdast-util-mdx-jsx/lib/index.d.ts","./node_modules/.pnpm/mdast-util-mdx-jsx@3.2.0/node_modules/mdast-util-mdx-jsx/index.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/mdx-jsx-element.d.ts","./node_modules/.pnpm/mdast-util-mdxjs-esm@2.0.1/node_modules/mdast-util-mdxjs-esm/lib/index.d.ts","./node_modules/.pnpm/mdast-util-mdxjs-esm@2.0.1/node_modules/mdast-util-mdxjs-esm/index.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/mdxjs-esm.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/root.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/text.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/handlers/index.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/index.d.ts","./node_modules/.pnpm/property-information@7.1.0/node_modules/property-information/lib/util/info.d.ts","./node_modules/.pnpm/property-information@7.1.0/node_modules/property-information/lib/find.d.ts","./node_modules/.pnpm/property-information@7.1.0/node_modules/property-information/lib/hast-to-react.d.ts","./node_modules/.pnpm/property-information@7.1.0/node_modules/property-information/lib/normalize.d.ts","./node_modules/.pnpm/property-information@7.1.0/node_modules/property-information/index.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/lib/state.d.ts","./node_modules/.pnpm/hast-util-to-estree@3.1.3/node_modules/hast-util-to-estree/index.d.ts","./node_modules/.pnpm/rehype-recma@1.0.0/node_modules/rehype-recma/lib/index.d.ts","./node_modules/.pnpm/rehype-recma@1.0.0/node_modules/rehype-recma/index.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/state.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/footer.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/blockquote.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/break.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/code.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/delete.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/emphasis.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/footnote-reference.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/heading.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/html.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/image-reference.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/image.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/inline-code.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/link-reference.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/link.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/list-item.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/list.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/paragraph.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/root.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/strong.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/table.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/table-cell.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/table-row.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/text.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/thematic-break.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/handlers/index.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/lib/index.d.ts","./node_modules/.pnpm/mdast-util-to-hast@13.2.1/node_modules/mdast-util-to-hast/index.d.ts","./node_modules/.pnpm/remark-rehype@11.1.2/node_modules/remark-rehype/lib/index.d.ts","./node_modules/.pnpm/remark-rehype@11.1.2/node_modules/remark-rehype/index.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/lib/core.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/lib/node-types.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/lib/compile.d.ts","./node_modules/.pnpm/hast-util-to-jsx-runtime@2.3.6/node_modules/hast-util-to-jsx-runtime/lib/types.d.ts","./node_modules/.pnpm/hast-util-to-jsx-runtime@2.3.6/node_modules/hast-util-to-jsx-runtime/lib/index.d.ts","./node_modules/.pnpm/hast-util-to-jsx-runtime@2.3.6/node_modules/hast-util-to-jsx-runtime/index.d.ts","./node_modules/.pnpm/@types+mdx@2.0.13/node_modules/@types/mdx/types.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/lib/util/resolve-evaluate-options.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/lib/evaluate.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/lib/run.d.ts","./node_modules/.pnpm/@mdx-js+mdx@3.1.1/node_modules/@mdx-js/mdx/index.d.ts","./node_modules/.pnpm/@standard-schema+spec@1.1.0/node_modules/@standard-schema/spec/dist/index.d.ts","./node_modules/.pnpm/micromark-extension-gfm-footnote@2.1.0/node_modules/micromark-extension-gfm-footnote/lib/html.d.ts","./node_modules/.pnpm/micromark-extension-gfm-footnote@2.1.0/node_modules/micromark-extension-gfm-footnote/lib/syntax.d.ts","./node_modules/.pnpm/micromark-extension-gfm-footnote@2.1.0/node_modules/micromark-extension-gfm-footnote/index.d.ts","./node_modules/.pnpm/micromark-extension-gfm-strikethrough@2.1.0/node_modules/micromark-extension-gfm-strikethrough/lib/html.d.ts","./node_modules/.pnpm/micromark-extension-gfm-strikethrough@2.1.0/node_modules/micromark-extension-gfm-strikethrough/lib/syntax.d.ts","./node_modules/.pnpm/micromark-extension-gfm-strikethrough@2.1.0/node_modules/micromark-extension-gfm-strikethrough/index.d.ts","./node_modules/.pnpm/micromark-extension-gfm@3.0.0/node_modules/micromark-extension-gfm/index.d.ts","./node_modules/.pnpm/mdast-util-gfm-footnote@2.1.0/node_modules/mdast-util-gfm-footnote/lib/index.d.ts","./node_modules/.pnpm/mdast-util-gfm-footnote@2.1.0/node_modules/mdast-util-gfm-footnote/index.d.ts","./node_modules/.pnpm/markdown-table@3.0.4/node_modules/markdown-table/index.d.ts","./node_modules/.pnpm/mdast-util-gfm-table@2.0.0/node_modules/mdast-util-gfm-table/lib/index.d.ts","./node_modules/.pnpm/mdast-util-gfm-table@2.0.0/node_modules/mdast-util-gfm-table/index.d.ts","./node_modules/.pnpm/mdast-util-gfm@3.1.0/node_modules/mdast-util-gfm/lib/index.d.ts","./node_modules/.pnpm/mdast-util-gfm@3.1.0/node_modules/mdast-util-gfm/index.d.ts","./node_modules/.pnpm/remark-gfm@4.0.1/node_modules/remark-gfm/lib/index.d.ts","./node_modules/.pnpm/remark-gfm@4.0.1/node_modules/remark-gfm/index.d.ts","./node_modules/.pnpm/@shikijs+vscode-textmate@10.0.2/node_modules/@shikijs/vscode-textmate/dist/index.d.ts","./node_modules/.pnpm/@shikijs+types@3.22.0/node_modules/@shikijs/types/dist/index.d.mts","./node_modules/.pnpm/shiki@3.22.0/node_modules/shiki/dist/langs.d.mts","./node_modules/.pnpm/stringify-entities@4.0.4/node_modules/stringify-entities/lib/util/format-smart.d.ts","./node_modules/.pnpm/stringify-entities@4.0.4/node_modules/stringify-entities/lib/core.d.ts","./node_modules/.pnpm/stringify-entities@4.0.4/node_modules/stringify-entities/lib/index.d.ts","./node_modules/.pnpm/stringify-entities@4.0.4/node_modules/stringify-entities/index.d.ts","./node_modules/.pnpm/hast-util-to-html@9.0.5/node_modules/hast-util-to-html/lib/index.d.ts","./node_modules/.pnpm/hast-util-to-html@9.0.5/node_modules/hast-util-to-html/index.d.ts","./node_modules/.pnpm/@shikijs+core@3.22.0/node_modules/@shikijs/core/dist/index.d.mts","./node_modules/.pnpm/shiki@3.22.0/node_modules/shiki/dist/themes.d.mts","./node_modules/.pnpm/shiki@3.22.0/node_modules/shiki/dist/bundle-full.d.mts","./node_modules/.pnpm/@shikijs+core@3.22.0/node_modules/@shikijs/core/dist/types.d.mts","./node_modules/.pnpm/shiki@3.22.0/node_modules/shiki/dist/types.d.mts","./node_modules/.pnpm/oniguruma-to-es@4.3.4/node_modules/oniguruma-to-es/dist/esm/subclass.d.ts","./node_modules/.pnpm/oniguruma-to-es@4.3.4/node_modules/oniguruma-to-es/dist/esm/index.d.ts","./node_modules/.pnpm/@shikijs+engine-javascript@3.22.0/node_modules/@shikijs/engine-javascript/dist/shared/engine-javascript.cdednu-m.d.mts","./node_modules/.pnpm/@shikijs+engine-javascript@3.22.0/node_modules/@shikijs/engine-javascript/dist/engine-raw.d.mts","./node_modules/.pnpm/@shikijs+engine-javascript@3.22.0/node_modules/@shikijs/engine-javascript/dist/index.d.mts","./node_modules/.pnpm/@shikijs+engine-oniguruma@3.22.0/node_modules/@shikijs/engine-oniguruma/dist/chunk-index.d.d.mts","./node_modules/.pnpm/@shikijs+engine-oniguruma@3.22.0/node_modules/@shikijs/engine-oniguruma/dist/index.d.mts","./node_modules/.pnpm/shiki@3.22.0/node_modules/shiki/dist/index.d.mts","./node_modules/.pnpm/@shikijs+rehype@3.22.0/node_modules/@shikijs/rehype/dist/shared/rehype.dcmmi29i.d.mts","./node_modules/.pnpm/@shikijs+rehype@3.22.0/node_modules/@shikijs/rehype/dist/index.d.mts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/rehype-code.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-image.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-structure.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-heading.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-admonition.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-directive-admonition.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/rehype-toc.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-code-tab.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-steps.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-npm.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/codeblock-utils.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-mdx-files.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/remark-mdx-mermaid.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/mdx-plugins/index.d.ts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/standard-schema.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/registries.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/to-json-schema.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/util.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/versions.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/schemas.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/checks.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/errors.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/core.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/parse.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/regexes.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ar.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/az.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/be.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/bg.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ca.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/cs.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/da.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/de.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/en.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/eo.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/es.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fa.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fi.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fr.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/fr-ca.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/he.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/hu.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/hy.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/id.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/is.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/it.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ja.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ka.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/kh.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/km.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ko.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/lt.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/mk.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ms.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/nl.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/no.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ota.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ps.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/pl.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/pt.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ru.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/sl.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/sv.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ta.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/th.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/tr.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ua.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/uk.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/ur.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/uz.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/vi.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/zh-cn.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/zh-tw.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/yo.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/locales/index.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/doc.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/api.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema-processors.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/json-schema-generator.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/core/index.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/errors.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/parse.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/schemas.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/checks.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/compat.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/from-json-schema.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/iso.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/coerce.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/v4/classic/external.d.cts","./node_modules/.pnpm/zod@4.3.6/node_modules/zod/index.d.cts","./node_modules/.pnpm/readdirp@4.1.2/node_modules/readdirp/esm/index.d.ts","./node_modules/.pnpm/chokidar@4.0.3/node_modules/chokidar/esm/handler.d.ts","./node_modules/.pnpm/chokidar@4.0.3/node_modules/chokidar/esm/index.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/definitions-dbcug1p3.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/i18n/index.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/loader-dgl6g4ca.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/source/index.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/toc.d.ts","./node_modules/.pnpm/fumadocs-mdx@14.0.3_fumadocs-core@16.1.0_next@16.1.6_react@19.2.4_vite@7.3.1/node_modules/fumadocs-mdx/dist/runtime/types.d.ts","./node_modules/.pnpm/fumadocs-mdx@14.0.3_fumadocs-core@16.1.0_next@16.1.6_react@19.2.4_vite@7.3.1/node_modules/fumadocs-mdx/dist/core-c3qzsdex.d.ts","./node_modules/.pnpm/fumadocs-mdx@14.0.3_fumadocs-core@16.1.0_next@16.1.6_react@19.2.4_vite@7.3.1/node_modules/fumadocs-mdx/dist/config/index.d.ts","./node_modules/.pnpm/fumadocs-mdx@14.0.3_fumadocs-core@16.1.0_next@16.1.6_react@19.2.4_vite@7.3.1/node_modules/fumadocs-mdx/dist/plugins/last-modified.d.ts","./source.config.ts","./node_modules/.pnpm/lucide-static@0.552.0/node_modules/lucide-static/dist/lucide-static.d.ts","./node_modules/.pnpm/fumadocs-mdx@14.0.3_fumadocs-core@16.1.0_next@16.1.6_react@19.2.4_vite@7.3.1/node_modules/fumadocs-mdx/dist/runtime/server.d.ts","./.source/server.ts","./lib/source.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/insert.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/constants.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/internal-document-id-store.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/trees/radix.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/trees/avl.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/trees/flat.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/trees/bkd.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/trees/bool.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/trees/vector.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/index.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/sorter.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/tokenizer/languages.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/answer-session.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/tokenizer/index.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/types.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/documents-store.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/create.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/docs.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/pinning.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/pinning.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/remove.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/search.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/search-vector.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/serialization.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/update.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/methods/upsert.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/utils.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/defaults.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/components/levenshtein.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/internals.d.ts","./node_modules/.pnpm/@orama+orama@3.1.18/node_modules/@orama/orama/dist/esm/index.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/search/index.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/search/server.d.ts","./app/api/search/route.ts","./node_modules/.pnpm/next-themes@0.4.6_react-dom@19.2.4_react@19.2.4/node_modules/next-themes/dist/index.d.ts","./node_modules/.pnpm/@radix-ui+react-context@1.1.2_@types+react@19.2.14_react@19.2.4/node_modules/@radix-ui/react-context/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-primitive@2.1.3_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-primitive/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-dismissable-layer@1.1.11_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-dismissable-layer/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-focus-scope@1.1.7_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-focus-scope/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-portal@1.1.9_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-portal/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-dialog@1.1.15_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-dialog/dist/index.d.mts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/contexts/search.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/dialog/search.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/dialog/search-default.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/contexts/i18n.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/provider/base.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/framework/index.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/provider/next.d.ts","./app/layout.tsx","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/page-tree/index.d.ts","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/link.d.ts","./node_modules/.pnpm/@radix-ui+react-scroll-area@1.2.10_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-scroll-area/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-collapsible@1.1.12_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-collapsible/dist/index.d.mts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/layout/sidebar.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/utils/get-sidebar-tabs.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/layout/root-toggle.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/contexts/layout.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/layouts/shared/client.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/layouts/shared/index.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/layouts/docs/client.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/layouts/docs/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/image-component.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/image.d.ts","./lib/layout.shared.tsx","./app/[[...slug]]/layout.tsx","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/breadcrumb.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/layouts/docs/page-client.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/layouts/docs/page.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/page.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/get-page-files.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/compatibility/iterators.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/globals.typedarray.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/buffer.buffer.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/globals.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/abortcontroller.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/crypto.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/domexception.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/events.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/utility.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/header.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/readable.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/fetch.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/formdata.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/connector.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/client-stats.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/client.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/errors.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/dispatcher.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/global-dispatcher.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/global-origin.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/pool-stats.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/pool.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/handlers.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/balanced-pool.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/h2c-client.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/agent.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/mock-interceptor.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/mock-call-history.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/mock-agent.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/mock-client.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/mock-pool.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/snapshot-agent.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/mock-errors.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/proxy-agent.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/retry-handler.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/retry-agent.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/api.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/cache-interceptor.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/interceptors.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/util.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/cookies.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/patch.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/websocket.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/eventsource.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/content-type.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/cache.d.ts","./node_modules/.pnpm/undici-types@7.16.0/node_modules/undici-types/index.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/fetch.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/navigator.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/storage.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/web-globals/streams.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/assert.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/assert/strict.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/async_hooks.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/buffer.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/child_process.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/cluster.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/console.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/constants.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/crypto.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/dgram.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/dns.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/dns/promises.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/domain.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/events.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/fs.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/fs/promises.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/http.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/http2.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/https.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/inspector.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/inspector.generated.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/module.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/net.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/os.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/path.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/perf_hooks.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/process.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/punycode.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/querystring.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/readline.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/readline/promises.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/repl.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/sea.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/sqlite.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/stream.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/stream/promises.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/stream/consumers.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/stream/web.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/string_decoder.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/test.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/timers.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/timers/promises.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/tls.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/trace_events.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/tty.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/url.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/util.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/v8.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/vm.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/wasi.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/worker_threads.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/zlib.d.ts","./node_modules/.pnpm/@types+node@24.10.13/node_modules/@types/node/index.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/canary.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/experimental.d.ts","./node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom/index.d.ts","./node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom/canary.d.ts","./node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom/experimental.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/fallback.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/entry-constants.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/config.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/body-streams.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-kind.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-definitions/route-definition.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-matches/route-match.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/cache-control.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/cache-handlers/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/resume-data-cache/cache-store.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/resume-data-cache/resume-data-cache.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/constants.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/render-result.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/userspace/pages/pages-dev-overlay-setup.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/app-router-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/static-paths/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-definitions/app-page-route-definition.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/instrumentation/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/worker.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/bundler.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/experimental/ppr.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/page-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/segment-config/app/app-segment-config.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/segment-config/pages/pages-segment-config.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/require-hook.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-baseline.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/error-inspect.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/console-file.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/console-exit.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/console-dim.external.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/unhandled-rejection.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/random.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/date.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/web-crypto.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/node-crypto.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment-extensions/fast-set-immediate.external.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/node-environment.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-page/module.compiled.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-definitions/app-route-route-definition.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/i18n-provider.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/next-url.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-definitions/locale-route-definition.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-definitions/pages-route-definition.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/with-router.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/router.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/route-loader.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/page-loader.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/readonly-url-search-params.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/flight-data-helpers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/router-reducer/ppr-navigations.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/segment-cache/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/segment-cache/navigation.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/segment-cache/cache-key.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/pages/module.compiled.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/templates/pages.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/pages/module.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/render.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-definitions/pages-api-route-definition.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-matches/pages-api-route-match.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-matchers/route-matcher.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/normalizer.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/locale-route-normalizer.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/request/pathname-normalizer.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/request/suffix.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/request/rsc.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/request/next-data.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/after/builtin-request-context.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/normalizers/request/segment-prefix-rsc.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/pages/builtin/_error.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/load-default-error-components.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/base-server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/use-cache/cache-life.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/async-storage/work-store.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/http.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-route/shared-modules.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/redirect-error.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/cache-signal.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/work-unit-async-storage-instance.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/lazy-result.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/implicit-tags.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/staged-rendering.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/work-unit-async-storage.external.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/templates/app-route.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/action-async-storage-instance.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/action-async-storage.external.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-route/module.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-route/module.compiled.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/segment-config/app/app-segments.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/swc/generated-native.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/swc/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/utils.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/rendering-mode.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/router-utils/build-prefetch-segment-data-route.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/turborepo-access-trace/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/turborepo-access-trace/result.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/turborepo-access-trace/helpers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/turborepo-access-trace/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/export/routes/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/export/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/export/worker.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/worker.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/trace/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/trace/trace.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/trace/shared.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/trace/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/.pnpm/@next+env@16.1.6/node_modules/@next/env/dist/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/telemetry-plugin/use-cache-tracker-utils.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/plugins/telemetry-plugin/telemetry-plugin.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/telemetry/storage.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/build-context.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack-config.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/shared/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/dev/dev-indicator-server-state.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/dev-overlay/cache-indicator.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/parse-stack.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/server/shared.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/shared/stack-frame.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/dev-overlay/utils/get-error-by-type.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/dev-overlay/container/runtime-error/render-error.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/dev-overlay/shared.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/dev/debug-channel.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/segment-config/middleware/middleware-config.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/base-http/node.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/async-callback-set.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/.pnpm/sharp@0.34.5/node_modules/sharp/lib/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/next-server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/lru-cache.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/next.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/router-utils/router-server-context.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/route-module.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/load-components.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/adapter.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/webpack/loaders/next-app-loader/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/router/utils/parse-relative-url.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/client-page.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/client-segment.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/search-params.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/http-access-fallback/error-boundary.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/resolvers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/types/icons.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/resolve-metadata.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/metadata/metadata.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/lib/framework/boundary-components.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/segment-cache/segment-value-encoding.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/collect-segment-data.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/next-devtools/userspace/app/segment-explorer-node.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/jsx-dev-runtime.d.ts","./node_modules/.pnpm/@types+react@19.2.14/node_modules/@types/react/compiler-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-page/vendored/rsc/entrypoints.d.ts","./node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom/client.d.ts","./node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom/static.d.ts","./node_modules/.pnpm/@types+react-dom@19.2.3_@types+react@19.2.14/node_modules/@types/react-dom/server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-page/vendored/ssr/entrypoints.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/route-modules/app-page/module.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/fallback-params.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request-meta.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/cli/next-test.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/size-limit.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/config-shared.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/base-http/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/build/adapter/build-complete.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/after/after.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/after/after-context.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/work-async-storage-instance.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/action-revalidation-kind.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/app-render/work-async-storage.external.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/params.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/unrecognized-action-error.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/redirect.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/not-found.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/forbidden.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/unauthorized.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/unstable-rethrow.server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/unstable-rethrow.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/components/navigation.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/navigation.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/card.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/callout.d.ts","./node_modules/.pnpm/@radix-ui+react-roving-focus@1.1.11_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-roving-focus/dist/index.d.mts","./node_modules/.pnpm/@radix-ui+react-tabs@1.1.13_@types+react-dom@19.2.3_@types+react@19.2.14_react-dom@19.2.4_react@19.2.4/node_modules/@radix-ui/react-tabs/dist/index.d.mts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/tabs.unstyled.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/components/codeblock.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/mdx.server.d.ts","./node_modules/.pnpm/fumadocs-ui@16.1.0_@types+react-dom@19.2.3_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4__p7khta47lz3jqrc7k4gav3dphu/node_modules/fumadocs-ui/dist/mdx.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/pages/_app.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/app.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/use-cache/cache-tag.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/cache.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/pages/_document.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/document.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dynamic.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/pages/_error.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/error.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/shared/lib/head.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/head.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/cookies.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/headers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/draft-mode.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/headers.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/link.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/link.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/router.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/client/script.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/script.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/after/index.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/dist/server/request/connection.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/server.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/types/global.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/types/compiled.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/types.d.ts","./node_modules/.pnpm/next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/next/index.d.ts","./components/copymarkdownbutton.tsx","./components/lastupdated.tsx","./node_modules/.pnpm/fumadocs-core@16.1.0_@types+react@19.2.14_next@16.1.6_react-dom@19.2.4_react@19.2.4/node_modules/fumadocs-core/dist/content/github.d.ts","./app/[[...slug]]/page.tsx","./components/changelog.tsx","./node_modules/.pnpm/@types+mdx@2.0.13/node_modules/@types/mdx/index.d.ts"],"fileIdsList":[[65,313,317,319,398,452,469,470],[64,65,321,385,389,398,452,469,470],[65,321,394,398,452,469,470,776,784,826,827,828,829],[65,321,355,398,452,469,470],[64,65,372,398,452,469,470],[64,65,398,452,469,470],[65,398,452,469,470],[65,383,388,398,452,469,470],[65,311,318,320,398,452,469,470],[162,163,164,167,169,170,171,398,452,469,470],[73,162,216,398,452,469,470],[67,69,78,79,111,114,117,131,159,161,216,217,221,398,452,469,470],[73,168,169,216,398,452,469,470],[398,452,469,470],[168,169,398,452,469,470],[164,167,168,398,452,469,470],[324,331,332,335,337,349,398,452,469,470],[328,336,348,398,452,469,470],[324,336,398,452,469,470],[322,324,325,326,327,328,329,330,336,398,452,469,470],[336,398,452,469,470],[324,398,452,469,470],[333,336,398,452,469,470],[322,333,334,336,338,339,340,341,342,343,344,345,346,347,350,352,398,452,469,470],[335,348,351,398,452,469,470],[331,332,336,337,398,452,469,470],[336,340,398,452,469,470],[330,336,353,398,452,469,470],[336,353,398,452,469,470],[322,336,398,452,469,470],[322,323,324,328,330,331,332,333,334,335,337,398,452,469,470],[64,358,359,398,452,469,470],[64,398,452,469,470],[64,358,359,360,361,362,398,452,469,470],[64,359,398,452,469,470],[64,358,359,398,452,469,470,779],[80,111,114,117,159,191,198,398,452,469,470],[190,191,398,452,469,470],[191,398,452,469,470],[190,191,205,206,207,398,452,469,470],[190,191,205,398,452,469,470],[209,398,452,469,470],[78,80,111,114,117,159,191,211,212,398,452,469,470],[80,111,114,117,159,211,398,452,469,470],[80,111,114,117,159,190,398,452,469,470],[68,69,398,452,469,470],[66,398,452,469,470],[168,398,452,469,470,832],[398,449,450,452,469,470],[398,451,452,469,470],[452,469,470],[398,452,457,469,470,487],[398,452,453,458,463,469,470,472,484,495],[398,452,453,454,463,469,470,472],[398,452,455,469,470,496],[398,452,456,457,464,469,470,473],[398,452,457,469,470,484,492],[398,452,458,460,463,469,470,472],[398,451,452,459,469,470],[398,452,460,461,469,470],[398,452,462,463,469,470],[398,451,452,463,469,470],[398,452,463,464,465,469,470,484,495],[398,452,463,464,465,469,470,479,484,487],[398,444,452,460,463,466,469,470,472,484,495],[398,452,463,464,466,467,469,470,472,484,492,495],[398,452,466,468,469,470,484,492,495],[396,397,398,399,400,401,402,403,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501],[398,452,463,469,470],[398,452,469,470,471,495],[398,452,460,463,469,470,472,484],[398,452,469,470,473],[398,452,469,470,474],[398,451,452,469,470,475],[398,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501],[398,452,469,470,477],[398,452,469,470,478],[398,452,463,469,470,479,480],[398,452,469,470,479,481,496,498],[398,452,464,469,470],[398,452,463,469,470,484,485,487],[398,452,469,470,486,487],[398,452,469,470,484,485],[398,452,469,470,487],[398,452,469,470,488],[398,449,452,469,470,484,489,495],[398,452,463,469,470,490,491],[398,452,469,470,490,491],[398,452,457,469,470,472,484,492],[398,452,469,470,493],[398,452,469,470,472,494],[398,452,466,469,470,478,495],[398,452,457,469,470,496],[398,452,469,470,484,497],[398,452,469,470,471,498],[398,452,469,470,499],[398,452,457,469,470],[398,444,452,469,470],[398,452,469,470,500],[398,444,452,463,465,469,470,475,484,487,495,497,498,500],[398,452,469,470,484,501],[64,398,452,469,470,503,504,505,507,756,788,819],[64,398,452,469,470,503,504,505,506,743,756,788,819],[64,398,452,469,470,503,504,506,507,756,788,819],[64,398,452,469,470,507,743,744],[64,398,452,469,470,507,743],[64,398,452,469,470,504,505,506,507,756,788,819],[64,398,452,469,470,503,505,506,507,756,788,819],[62,63,398,452,469,470],[305,307,398,452,464,469,470],[305,306,398,452,463,464,469,470],[64,308,398,452,469,470],[64,65,370,398,452,469,470],[64,308,309,398,452,469,470],[67,111,114,117,159,216,217,221,398,452,469,470],[67,78,80,111,114,117,159,189,211,213,214,215,216,217,218,219,220,221,222,223,224,225,226,398,452,469,470],[78,80,111,114,117,159,211,213,398,452,469,470],[78,80,111,114,117,159,398,452,469,470],[67,78,111,114,117,159,216,217,221,398,452,469,470],[67,73,78,111,114,117,159,216,217,221,398,452,469,470],[64,67,78,111,114,117,159,216,217,221,308,309,310,353,354,398,452,469,470],[64,308,309,310,398,452,469,470],[67,73,78,111,114,117,159,168,172,173,216,217,221,227,304,307,311,312,313,314,398,452,469,470],[73,78,172,173,216,227,304,307,311,313,398,452,469,470],[67,111,114,117,159,168,216,217,221,312,398,452,469,470],[64,65,398,452,469,470,781],[64,65,364,365,398,452,469,470],[64,65,354,363,364,398,452,469,470],[64,65,379,398,452,469,470],[64,65,374,375,376,377,398,452,469,470],[64,65,398,452,469,470,780],[64,65,380,398,452,469,470],[64,65,374,378,379,380,383,384,398,452,469,470],[64,65,374,391,398,452,469,470],[64,65,312,392,398,452,469,470],[64,65,383,398,452,469,470],[64,309,381,382,398,452,469,470],[64,65,398,452,469,470,777,778,782,783],[64,311,398,452,469,470,784],[64,65,312,393,398,452,469,470],[64,65,357,364,366,367,398,452,469,470],[64,65,368,371,398,452,469,470],[64,374,398,452,469,470],[121,122,128,398,452,469,470],[69,80,111,114,117,129,159,398,452,469,470],[81,82,112,115,118,119,120,398,452,469,470],[69,111,129,398,452,469,470],[69,114,129,398,452,469,470],[117,129,398,452,469,470],[68,69,80,111,114,117,129,159,398,452,469,470],[68,69,80,111,114,117,127,159,398,452,469,470],[197,398,452,469,470],[80,111,114,117,127,159,196,398,452,469,470],[165,166,398,452,469,470],[80,111,114,117,159,165,167,398,452,469,470],[83,84,85,86,176,179,398,452,469,470],[67,83,84,86,111,114,117,159,176,179,216,217,221,398,452,469,470],[67,83,86,111,114,117,159,176,179,216,217,221,398,452,469,470],[109,114,181,185,398,452,469,470],[86,109,114,182,185,398,452,469,470],[86,109,114,182,184,398,452,469,470],[67,86,109,111,114,117,159,182,183,185,216,217,221,398,452,469,470],[182,185,186,398,452,469,470],[86,109,114,182,185,187,398,452,469,470],[67,69,80,110,111,114,117,159,216,217,221,398,452,469,470],[66,67,69,80,86,109,111,113,114,117,159,182,185,216,217,221,398,452,469,470],[67,69,80,111,114,116,117,159,216,217,221,398,452,469,470],[86,109,114,117,182,185,398,452,469,470],[67,80,111,114,117,132,133,157,158,159,216,217,221,398,452,469,470],[80,111,114,117,132,159,398,452,469,470],[67,80,111,114,117,132,159,216,217,221,398,452,469,470],[134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,398,452,469,470],[67,73,80,111,114,117,133,159,216,217,221,398,452,469,470],[87,88,108,398,452,469,470],[67,109,111,114,117,159,182,185,216,217,221,398,452,469,470],[89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,398,452,469,470],[66,67,111,114,117,159,216,217,221,398,452,469,470],[83,86,174,175,179,398,452,469,470],[83,86,176,179,398,452,469,470],[83,86,176,177,178,398,452,469,470],[398,452,469,470,790],[398,452,469,470,792,793,794,795],[398,452,469,470,512,514,521,543,643,653,752],[398,452,469,470,514,538,539,540,542,752],[398,452,469,470,514,659,661,663,664,666,752,754],[398,452,469,470,514,520,521,525,531,535,536,642,643,644,652,752,754],[398,452,469,470,752],[398,452,469,470,530,539,559,638,766],[398,452,469,470,514],[398,452,469,470,508,530,766],[398,452,469,470,641],[398,452,469,470,640,752],[398,452,466,469,470,559,738,824],[398,452,466,469,470,620,633,638,765],[398,452,466,469,470,596],[398,452,469,470,646],[398,452,469,470,645,646,647],[398,452,469,470,645],[395,398,452,466,469,470,508,514,521,525,531,537,539,543,544,557,558,617,639,641,653,752,756],[398,452,469,470,512,514,541,577,659,660,665,752,824],[398,452,469,470,541,824],[398,452,469,470,512,558,707,752,824],[398,452,469,470,824],[398,452,469,470,514,541,542,824],[398,452,469,470,662,824],[398,452,469,470,544,642,651],[65,398,452,469,470,478,766],[65,398,452,469,470,766],[64,398,452,469,470,713],[398,452,469,470,584,593,594,766,767,774],[398,452,469,470,583,623,768,769,770,771,773],[398,452,469,470,622],[398,452,469,470,622,623],[398,452,469,470,520,530,586,590],[398,452,469,470,530],[398,452,469,470,530,589,591],[398,452,469,470,530,586,587,588],[398,452,469,470,772],[64,369,370,398,452,469,470],[64,398,452,469,470,495],[64,398,452,469,470,541,575],[64,398,452,469,470,541,653],[398,452,469,470,573,578],[64,398,452,469,470,574,758],[64,398,452,466,469,470,502,503,504,505,506,507,756,788,817,818],[398,452,466,469,470],[398,452,466,469,470,521,524,599,616,648,649,653,704,706,752,753],[398,452,469,470,557,650],[398,452,469,470,756],[398,452,469,470,513],[64,398,452,469,470,709,711,718,727,729,765],[398,452,469,470,478,709,711,726,727,728,765,823],[398,452,469,470,720,721,722,723,724,725],[398,452,469,470,722],[398,452,469,470,726],[65,398,452,469,470,673,674,676],[64,398,452,469,470,667,668,669,670,675],[398,452,469,470,673,675],[398,452,469,470,671],[398,452,469,470,672],[64,65,398,452,469,470,574,758],[64,65,398,452,469,470,757,758],[64,65,398,452,469,470,758],[398,452,469,470,616,760],[398,452,469,470,760],[398,452,466,469,470,753,758],[398,452,469,470,636],[398,451,452,469,470,635],[398,452,469,470,526,528,530,631,633,706,710,747,748,749,753,765],[398,452,469,470,530,568,735],[398,452,469,470,633,765],[64,398,452,469,470,620,633,636,714,715,716,717,718,719,730,731,732,733,734,736,737,765,766,824],[398,452,469,470,628],[369,398,452,466,469,470,478,524,533,566,569,616,617,670,704,705,747,752,753,754,756,759,824],[398,452,469,470,765],[398,451,452,469,470,539,566,617,630,753,759,761,762,763,764],[398,452,469,470,633],[398,451,452,469,470,524,528,564,624,625,626,627,628,629,631,632,748,765,766],[398,452,466,469,470,564,565,624,753,754],[398,452,469,470,539,616,617,706,753,759,765],[398,452,466,469,470,752,754],[398,452,466,469,470,484,749,753,754],[398,452,466,469,470,478,495,508,521,526,528,531,533,541,561,566,567,568,569,599,600,602,605,607,610,611,612,613,615,653,704,706,749,752,753,754,759,766],[398,452,466,469,470,484],[369,398,452,469,470,514,515,537,749,750,751,756,758,824],[398,452,469,470,512,752],[398,452,469,470,678],[398,452,466,469,470,484,495,518,641,666,667,668,669,670,676,677,824],[398,452,469,470,478,495,508,518,528,531,600,605,615,616,659,682,683,684,690,693,694,704,706,749,752,759,766],[398,452,469,470,531,537,544,557,617,752,759],[369,398,452,466,469,470,495,521,528,688,749,752],[398,452,469,470,708],[398,452,466,469,470,678,691,692,701],[398,452,469,470,749,752],[398,452,469,470,630,748],[398,452,469,470,528,566,653,758],[398,452,466,469,470,478,605,655,659,684,690,693,696,749],[398,452,466,469,470,544,557,659,697],[398,452,469,470,514,567,653,699,752],[398,452,466,469,470,495,670,752],[398,452,466,469,470,541,567,653,654,655,664,678,698,700,752],[395,398,452,466,469,470,566,703,756,758],[398,452,469,470,614,704],[398,452,466,469,470,478,495,519,521,526,528,533,543,544,557,569,600,602,612,615,616,653,682,683,684,685,687,689,704,706,749,758,759,766],[398,452,466,469,470,484,544,690,695,701,749],[398,452,469,470,547,548,549,550,551,552,553,554,555,556],[398,452,469,470,561,606],[398,452,469,470,608],[398,452,469,470,606],[398,452,469,470,608,609],[398,452,466,469,470,520,521,524,525,753],[369,398,452,466,469,470,478,513,526,529,566,568,569,598,704,749,754,756,758],[398,452,466,469,470,478,495,516,519,520,528,529,748,753,759],[398,452,469,470,624],[398,452,469,470,625],[398,452,469,470,530,531,747],[398,452,469,470,626],[398,452,469,470,517,527],[398,452,466,469,470,517,521,526],[398,452,469,470,522,527],[398,452,469,470,523],[398,452,469,470,517,518],[398,452,469,470,517,570],[398,452,469,470,517],[398,452,469,470,519,561,604],[398,452,469,470,603],[398,452,469,470,518,519,766],[398,452,469,470,519,601],[398,452,469,470,518,766],[398,452,469,470,747],[398,452,469,470,521,526,528,530,532,566,643,653,703,706,709,711,712,739,742,746,748,749,753],[398,452,469,470,579,582,584,585,593,594],[64,65,398,452,469,470,505,507,740,741],[64,65,398,452,469,470,505,507,740,741,745],[398,452,469,470,637],[398,452,469,470,539,560,565,566,618,619,620,621,623,633,634,636,639,653,703,706,752,765],[398,452,469,470,593],[398,452,466,469,470,598],[398,452,469,470,598],[398,452,466,469,470,526,571,595,597,599,703,749,756,758],[398,452,469,470,579,580,581,582,584,585,593,594,757],[395,398,452,466,469,470,478,495,517,518,528,533,566,569,653,701,702,704,749,752,753,756,759],[398,452,469,470,565,679,682,759],[398,452,466,469,470,561,752],[398,452,469,470,564,633],[398,452,469,470,563],[398,452,469,470,565,612],[398,452,469,470,562,564,752],[398,452,466,469,470,516,565,679,680,681,752,753],[64,398,452,469,470,530,592,766],[398,452,469,470,510,511],[64,369,398,452,469,470],[64,398,452,469,470,583,766],[64,395,398,452,469,470,566,569,756,758],[369,370,386,398,452,469,470],[64,398,452,469,470,578],[64,398,452,469,470,478,495,513,572,574,576,577,758],[398,452,469,470,541,753,766],[398,452,469,470,686,766],[64,398,452,464,466,469,470,478,512,513,578,661,756,757],[64,398,452,469,470,503,504,505,506,507,756,819],[64,398,452,469,470,785,786,787,788],[398,452,469,470,656,657,658],[398,452,469,470,656],[64,398,452,466,468,469,470,478,502,503,504,505,506,507,508,513,533,696,726,754,755,758,788,819],[398,452,469,470,797],[398,452,469,470,799],[398,452,469,470,801],[398,452,469,470,803],[398,452,469,470,805,806,807],[387,398,452,469,470],[388,398,452,469,470,776,789,791,796,798,800,802,804,808,810,811,813,822,823,824,825],[398,452,469,470,809],[398,452,469,470,775],[398,452,469,470,574],[398,452,469,470,812],[398,451,452,469,470,565,679,680,682,814,815,816,819,820,821],[398,452,469,470,502],[204,398,452,469,470],[124,125,126,398,452,469,470],[123,127,398,452,469,470],[127,398,452,469,470],[398,452,464,469,470,484],[129,130,398,452,469,470],[68,69,80,111,114,117,131,159,398,452,469,470],[180,187,188,398,452,469,470],[189,398,452,469,470],[159,160,398,452,469,470],[67,73,78,80,111,114,117,159,216,217,221,398,452,469,470],[398,452,469,470,484,502],[80,111,114,117,159,191,192,199,200,398,452,469,470],[80,111,114,117,159,191,192,199,200,201,202,203,208,210,398,452,469,470],[199,398,452,469,470],[191,192,199,200,202,398,452,469,470],[195,398,452,469,470],[193,398,452,469,470],[193,194,398,452,469,470],[75,398,452,469,470],[398,410,413,416,417,452,469,470,495],[398,413,452,469,470,484,495],[398,413,417,452,469,470,495],[398,452,469,470,484],[398,407,452,469,470],[398,411,452,469,470],[398,409,410,413,452,469,470,495],[398,452,469,470,472,492],[398,407,452,469,470,502],[398,409,413,452,469,470,472,495],[398,404,405,406,408,412,452,463,469,470,484,495],[398,413,421,429,452,469,470],[398,405,411,452,469,470],[398,413,438,439,452,469,470],[398,405,408,413,452,469,470,487,495,502],[398,413,452,469,470],[398,409,413,452,469,470,495],[398,404,452,469,470],[398,407,408,409,411,412,413,414,415,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431,432,433,434,435,436,437,439,440,441,442,443,452,469,470],[398,413,431,434,452,460,469,470],[398,413,421,422,423,452,469,470],[398,411,413,422,424,452,469,470],[398,412,452,469,470],[398,405,407,413,452,469,470],[398,413,417,422,424,452,469,470],[398,417,452,469,470],[398,411,413,416,452,469,470,495],[398,405,409,413,421,452,469,470],[398,413,431,452,469,470],[398,424,452,469,470],[398,407,413,438,452,469,470,487,500,502],[73,77,216,398,452,469,470],[66,73,74,76,78,216,398,452,469,470],[70,398,452,469,470],[71,72,398,452,469,470],[66,71,73,216,398,452,469,470],[303,398,452,469,470],[294,398,452,469,470],[294,297,398,452,469,470],[289,292,294,295,296,297,298,299,300,301,302,398,452,469,470],[228,230,297,398,452,469,470],[294,295,398,452,469,470],[229,294,296,398,452,469,470],[230,232,234,235,236,237,398,452,469,470],[232,234,236,237,398,452,469,470],[232,234,236,398,452,469,470],[229,232,234,235,237,398,452,469,470],[228,230,231,232,233,234,235,236,237,238,239,289,290,291,292,293,398,452,469,470],[228,230,231,234,398,452,469,470],[230,231,234,398,452,469,470],[234,237,398,452,469,470],[228,229,231,232,233,235,236,237,398,452,469,470],[228,229,230,234,294,398,452,469,470],[234,235,236,237,398,452,469,470],[236,398,452,469,470],[240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,398,452,469,470],[65,315,316,398,452,469,470]],"fileInfos":[{"version":"c430d44666289dae81f30fa7b2edebf186ecc91a2d4c71266ea6ae76388792e1","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"080941d9f9ff9307f7e27a83bcd888b7c8270716c39af943532438932ec1d0b9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2e80ee7a49e8ac312cc11b77f1475804bee36b3b2bc896bead8b6e1266befb43","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fb0f136d372979348d59b3f5020b4cdb81b5504192b1cacff5d1fbba29378aa1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"a680117f487a4d2f30ea46f1b4b7f58bef1480456e18ba53ee85c2746eeca012","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"8cdf8847677ac7d20486e54dd3fcf09eda95812ac8ace44b4418da1bbbab6eb8","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"196cb558a13d4533a5163286f30b0509ce0210e4b316c56c38d4c0fd2fb38405","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"7e29f41b158de217f94cb9676bf9cbd0cd9b5a46e1985141ed36e075c52bf6ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"dc0a7f107690ee5cd8afc8dbf05c4df78085471ce16bdd9881642ec738bc81fe","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"89121c1bf2990f5219bfd802a3e7fc557de447c62058d6af68d6b6348d64499a","impliedFormat":1},{"version":"d4a22007b481fe2a2e6bfd3a42c00cd62d41edb36d30fc4697df2692e9891fc8","impliedFormat":1},{"version":"151ff381ef9ff8da2da9b9663ebf657eac35c4c9a19183420c05728f31a6761d","impliedFormat":1},{"version":"5d08a179b846f5ee674624b349ebebe2121c455e3a265dc93da4e8d9e89722b4","impliedFormat":1},{"version":"2b37ba54ec067598bf912d56fcb81f6d8ad86a045c757e79440bdef97b52fe1b","impliedFormat":99},{"version":"1bc9dd465634109668661f998485a32da369755d9f32b5a55ed64a525566c94b","impliedFormat":99},{"version":"5702b3c2f5d248290ed99419d77ca1cc3e6c29db5847172377659c50e6303768","impliedFormat":99},{"version":"9764b2eb5b4fc0b8951468fb3dbd6cd922d7752343ef5fbf1a7cd3dfcd54a75e","impliedFormat":99},{"version":"1fc2d3fe8f31c52c802c4dee6c0157c5a1d1f6be44ece83c49174e316cf931ad","impliedFormat":99},{"version":"dc4aae103a0c812121d9db1f7a5ea98231801ed405bf577d1c9c46a893177e36","impliedFormat":99},{"version":"106d3f40907ba68d2ad8ce143a68358bad476e1cc4a5c710c11c7dbaac878308","impliedFormat":99},{"version":"42ad582d92b058b88570d5be95393cf0a6c09a29ba9aa44609465b41d39d2534","impliedFormat":99},{"version":"36e051a1e0d2f2a808dbb164d846be09b5d98e8b782b37922a3b75f57ee66698","impliedFormat":99},{"version":"89dcbbf69b16cd94043e16c7fbcfa04256577ec98bb8ae894833d2a922394db4","impliedFormat":1},{"version":"79b4369233a12c6fa4a07301ecb7085802c98f3a77cf9ab97eee27e1656f82e6","impliedFormat":1},{"version":"d9a75d09e41d52d7e1c8315cc637f995820a4a18a7356a0d30b1bed6d798aa70","impliedFormat":99},{"version":"a76819b2b56ccfc03484098828bdfe457bc16adb842f4308064a424cb8dba3e4","impliedFormat":99},{"version":"a5dbd4c9941b614526619bad31047ddd5f504ec4cdad88d6117b549faef34dd3","impliedFormat":99},{"version":"011423c04bfafb915ceb4faec12ea882d60acbe482780a667fa5095796c320f8","impliedFormat":99},{"version":"f8eb2909590ec619643841ead2fc4b4b183fbd859848ef051295d35fef9d8469","impliedFormat":99},{"version":"fe784567dd721417e2c4c7c1d7306f4b8611a4f232f5b7ce734382cf34b417d2","impliedFormat":99},{"version":"45d1e8fb4fd3e265b15f5a77866a8e21870eae4c69c473c33289a4b971e93704","impliedFormat":99},{"version":"cd40919f70c875ca07ecc5431cc740e366c008bcbe08ba14b8c78353fb4680df","impliedFormat":99},{"version":"ddfd9196f1f83997873bbe958ce99123f11b062f8309fc09d9c9667b2c284391","impliedFormat":99},{"version":"2999ba314a310f6a333199848166d008d088c6e36d090cbdcc69db67d8ae3154","impliedFormat":99},{"version":"62c1e573cd595d3204dfc02b96eba623020b181d2aa3ce6a33e030bc83bebb41","impliedFormat":99},{"version":"ca1616999d6ded0160fea978088a57df492b6c3f8c457a5879837a7e68d69033","impliedFormat":99},{"version":"835e3d95251bbc48918bb874768c13b8986b87ea60471ad8eceb6e38ddd8845e","impliedFormat":99},{"version":"de54e18f04dbcc892a4b4241b9e4c233cfce9be02ac5f43a631bbc25f479cd84","impliedFormat":99},{"version":"453fb9934e71eb8b52347e581b36c01d7751121a75a5cd1a96e3237e3fd9fc7e","impliedFormat":99},{"version":"bc1a1d0eba489e3eb5c2a4aa8cd986c700692b07a76a60b73a3c31e52c7ef983","impliedFormat":99},{"version":"4098e612efd242b5e203c5c0b9afbf7473209905ab2830598be5c7b3942643d0","impliedFormat":99},{"version":"28410cfb9a798bd7d0327fbf0afd4c4038799b1d6a3f86116dc972e31156b6d2","impliedFormat":99},{"version":"514ae9be6724e2164eb38f2a903ef56cf1d0e6ddb62d0d40f155f32d1317c116","impliedFormat":99},{"version":"970e5e94a9071fd5b5c41e2710c0ef7d73e7f7732911681592669e3f7bd06308","impliedFormat":99},{"version":"491fb8b0e0aef777cec1339cb8f5a1a599ed4973ee22a2f02812dd0f48bd78c1","impliedFormat":99},{"version":"6acf0b3018881977d2cfe4382ac3e3db7e103904c4b634be908f1ade06eb302d","impliedFormat":99},{"version":"2dbb2e03b4b7f6524ad5683e7b5aa2e6aef9c83cab1678afd8467fde6d5a3a92","impliedFormat":99},{"version":"135b12824cd5e495ea0a8f7e29aba52e1adb4581bb1e279fb179304ba60c0a44","impliedFormat":99},{"version":"e4c784392051f4bbb80304d3a909da18c98bc58b093456a09b3e3a1b7b10937f","impliedFormat":99},{"version":"2e87c3480512f057f2e7f44f6498b7e3677196e84e0884618fc9e8b6d6228bed","impliedFormat":99},{"version":"66984309d771b6b085e3369227077da237b40e798570f0a2ddbfea383db39812","impliedFormat":99},{"version":"e41be8943835ad083a4f8a558bd2a89b7fe39619ed99f1880187c75e231d033e","impliedFormat":99},{"version":"260558fff7344e4985cfc78472ae58cbc2487e406d23c1ddaf4d484618ce4cfd","impliedFormat":99},{"version":"a3d5be0365b28b3281541d39d9db08d30b88de49576ddfbbb5d086155017b283","impliedFormat":99},{"version":"985d310b29f50ce5d4b4666cf2e5a06e841f3e37d1d507bd14186c78649aa3dd","impliedFormat":99},{"version":"af1120ba3de51e52385019b7800e66e4694ebc9e6a4a68e9f4afc711f6ae88be","impliedFormat":99},{"version":"5c6b3840cbc84f6f60abfc5c58c3b67b7296b5ebe26fd370710cfc89bbe3a5f1","impliedFormat":99},{"version":"91ef552cc29ec57d616e95d73ee09765198c710fa34e20b25cb9f9cf502821f1","impliedFormat":99},{"version":"25b6edf357caf505aa8e21a944bb0f7a166c8dac6a61a49ad1a0366f1bde5160","impliedFormat":99},{"version":"1ab840e4672a64e3c705a9163142e2b79b898db88b3c18400e37dbe88a58fa60","impliedFormat":99},{"version":"48516730c1cf1b72cac2da04481983cfe61359101d8563314457ecb059b102a9","impliedFormat":99},{"version":"d391200bb56f44a4be56e6571b2aeedfe602c0fd3c686b87b1306ae62e80b1e9","impliedFormat":99},{"version":"3b3e4b39cbb8adb1f210af60388e4ad66f6dfdeb45b3c8dde961f557776d88fe","impliedFormat":99},{"version":"431f31d10ad58b5767c57ffbf44198303b754193ba8fbf034b7cf8a3ab68abc1","impliedFormat":99},{"version":"a52180aca81ba4ef18ac145083d5d272c3a19f26db54441d5a7d8ef4bd601765","impliedFormat":99},{"version":"9de8aba529388309bc46248fb9c6cca493111a6c9fc1c1f087a3b281fb145d77","impliedFormat":99},{"version":"2ccdfd33a753c18e8e5fe8a1eadefff968531d920bc9cdc7e4c97b0c6d3dcaf8","impliedFormat":99},{"version":"d64a434d7fb5040dbe7d5f4911145deda53e281b3f1887b9a610defd51b3c1a2","impliedFormat":99},{"version":"927f406568919fd7cd238ef7fe5e9c5e9ec826f1fff89830e480aff8cfd197da","impliedFormat":99},{"version":"a77d742410fe78bb054d325b690fda75459531db005b62ba0e9371c00163353c","impliedFormat":99},{"version":"f8de61dd3e3c4dc193bb341891d67d3979cb5523a57fcacaf46bf1e6284e6c35","impliedFormat":99},{"version":"f07c5fb951dfaf5eb0c6053f6a77c67e02d21c9586c58ed0836d892e438c5bb2","impliedFormat":99},{"version":"c97b20bb0ad5d42e1475255cb13ede29fe1b8c398db5cba2a5842f1cb973b658","impliedFormat":99},{"version":"5559999a83ecfa2da6009cdab20b402c63cd6bb0f7a13fc033a5b567b3eb404b","impliedFormat":99},{"version":"aec26ed2e2ef8f2dbc6ffce8e93503f0c1a6b6cf50b6a13141a8462e7a6b8c79","impliedFormat":99},{"version":"9d62e577adb05f5aafed137e747b3a1b26f8dce7b20f350d22f6fb3255a3c0ed","impliedFormat":99},{"version":"7ed92bcef308af6e3925b3b61c83ad6157a03ff15c7412cf325f24042fe5d363","impliedFormat":99},{"version":"3da9062d0c762c002b7ab88187d72e1978c0224db61832221edc8f4eb0b54414","impliedFormat":99},{"version":"84dbf6af43b0b5ad42c01e332fddf4c690038248140d7c4ccb74a424e9226d4d","impliedFormat":99},{"version":"00884fc0ea3731a9ffecffcde8b32e181b20e1039977a8ae93ae5bce3ab3d245","impliedFormat":99},{"version":"0bd8b6493d9bf244afe133ccb52d32d293de8d08d15437cca2089beed5f5a6b5","impliedFormat":99},{"version":"7fc3099c95752c6e7b0ea215915464c7203e835fcd6878210f2ce4f0dcbbfe67","impliedFormat":99},{"version":"83b5499dbc74ee1add93aef162f7d44b769dcef3a74afb5f80c70f9a5ce77cc0","impliedFormat":99},{"version":"8bf8b772b38fc4da471248320f49a2219c363a9669938c720e0e0a5a2531eabf","impliedFormat":99},{"version":"7da6e8c98eacf084c961e039255f7ebb9d97a43377e7eee2695cb77fec640c66","impliedFormat":99},{"version":"0b5b064c5145a48cd3e2a5d9528c63f49bac55aa4bc5f5b4e68a160066401375","impliedFormat":99},{"version":"702ff40d28906c05d9d60b23e646c2577ad1cc7cd177d5c0791255a2eab13c07","impliedFormat":99},{"version":"49ff0f30d6e757d865ae0b422103f42737234e624815eee2b7f523240aa0c8f8","impliedFormat":99},{"version":"0389aacf0ffd49a877a46814a21a4770f33fc33e99951a1584de866c8e971993","impliedFormat":99},{"version":"5cb7a51cf151c1056b61f078cf80b811e19787d1f29a33a2a6e4bf00334bbc10","impliedFormat":99},{"version":"215aa8915d707f97ad511b7abbf7eda51d3a7048e9a656955cf0dda767ae7db0","impliedFormat":99},{"version":"0d689a717fbef83da07ab4de33f83db5cbcec9bc4e3b04edb106c538a50a0210","impliedFormat":99},{"version":"d00bc73e8d1f4137f2f6238bb3aa2bbdad8573658cc95920e2cdfa7ad491a8d8","impliedFormat":99},{"version":"e3667aa9f5245d1a99fb4a2a1ac48daf1429040c29cc0d262e3843f9ae3b9d65","impliedFormat":99},{"version":"08c0f3222b50ec2b534be1a59392660102549129246425d33ec43f35aa051dc6","impliedFormat":99},{"version":"612fb780f312e6bb3c40f3cb2b827ea7455b922198f651c799d844fdd44cf2e9","impliedFormat":99},{"version":"bcd98e8f44bc76e4fcb41e4b1a8bab648161a942653a3d1f261775a891d258de","impliedFormat":99},{"version":"5abaa19aa91bb4f63ea58154ada5d021e33b1f39aa026ca56eb95f13b12c497a","impliedFormat":99},{"version":"356a18b0c50f297fee148f4a2c64b0affd352cbd6f21c7b6bfa569d30622c693","impliedFormat":99},{"version":"5876027679fd5257b92eb55d62efee634358012b9f25c5711ad02b918e52c837","impliedFormat":99},{"version":"f5622423ee5642dcf2b92d71b37967b458e8df3cf90b468675ff9fddaa532a0f","impliedFormat":99},{"version":"70265bc75baf24ec0d61f12517b91ea711732b9c349fceef71a446c4ff4a247a","impliedFormat":99},{"version":"41a4b2454b2d3a13b4fc4ec57d6a0a639127369f87da8f28037943019705d619","impliedFormat":99},{"version":"e9b82ac7186490d18dffaafda695f5d975dfee549096c0bf883387a8b6c3ab5a","impliedFormat":99},{"version":"eed9b5f5a6998abe0b408db4b8847a46eb401c9924ddc5b24b1cede3ebf4ee8c","impliedFormat":99},{"version":"f99db2cd80274f32467ae5231d74632c35596ab75fa65baac3fec9dd551cf9d7","impliedFormat":99},{"version":"c799ceedd4821387e6f3518cf5725f9430e2fb7cae1d4606119a243dea28ee40","impliedFormat":99},{"version":"dcf54538d0bfa5006f03bf111730788a7dd409a49036212a36b678afa0a5d8c6","impliedFormat":99},{"version":"1ed428700390f2f81996f60341acef406b26ad72f74fc05afaf3ca101ae18e61","impliedFormat":99},{"version":"417048bbdce52a57110e6c221d6fa4e883bde6464450894f3af378a8b9a82a47","impliedFormat":99},{"version":"ab0048d2b673c0d60afc882a4154abcb2edb9a10873375366f090ae7ae336fe8","impliedFormat":99},{"version":"f8a6bb79327f4a6afc63d28624654522fc80f7536efa7a617ef48200b7a5f673","impliedFormat":1},{"version":"3e61b9db82b5e4a8ffcdd54812fda9d980cd4772b1d9f56b323524368eed9e5a","impliedFormat":99},{"version":"dcbc70889e6105d3e0a369dcea59a2bd3094800be802cd206b617540ff422708","impliedFormat":99},{"version":"f0d325b9e8d30a91593dc922c602720cec5f41011e703655d1c3e4e183a22268","impliedFormat":99},{"version":"afbd42eb9f22aa6a53aa4d5f8e09bb289dd110836908064d2a18ea3ab86a1984","impliedFormat":99},{"version":"bdd14f07b4eca0b4b5203b85b8dbc4d084c749fa590bee5ea613e1641dcd3b29","impliedFormat":99},{"version":"e87873f06fa094e76ac439c7756b264f3c76a41deb8bc7d39c1d30e0f03ef547","impliedFormat":99},{"version":"488861dc4f870c77c2f2f72c1f27a63fa2e81106f308e3fc345581938928f925","impliedFormat":99},{"version":"eff73acfacda1d3e62bb3cb5bc7200bb0257ea0c8857ce45b3fee5bfec38ad12","impliedFormat":99},{"version":"aff4ac6e11917a051b91edbb9a18735fe56bcfd8b1802ea9dbfb394ad8f6ce8e","impliedFormat":99},{"version":"1f68aed2648740ac69c6634c112fcaae4252fbae11379d6eabee09c0fbf00286","impliedFormat":99},{"version":"5e7c2eff249b4a86fb31e6b15e4353c3ddd5c8aefc253f4c3e4d9caeb4a739d4","impliedFormat":99},{"version":"14c8d1819e24a0ccb0aa64f85c61a6436c403eaf44c0e733cdaf1780fed5ec9f","impliedFormat":99},{"version":"413d50bc66826f899c842524e5f50f42d45c8cb3b26fd478a62f26ac8da3d90e","impliedFormat":99},{"version":"d9083e10a491b6f8291c7265555ba0e9d599d1f76282812c399ab7639019f365","impliedFormat":99},{"version":"09de774ebab62974edad71cb3c7c6fa786a3fda2644e6473392bd4b600a9c79c","impliedFormat":99},{"version":"e8bcc823792be321f581fcdd8d0f2639d417894e67604d884c38b699284a1a2a","impliedFormat":99},{"version":"7c99839c518dcf5ab8a741a97c190f0703c0a71e30c6d44f0b7921b0deec9f67","impliedFormat":99},{"version":"44c14e4da99cd71f9fe4e415756585cec74b9e7dc47478a837d5bedfb7db1e04","impliedFormat":99},{"version":"1f46ee2b76d9ae1159deb43d14279d04bcebcb9b75de4012b14b1f7486e36f82","impliedFormat":99},{"version":"2838028b54b421306639f4419606306b940a5c5fcc5bc485954cbb0ab84d90f4","impliedFormat":99},{"version":"7116e0399952e03afe9749a77ceaca29b0e1950989375066a9ddc9cb0b7dd252","impliedFormat":99},{"version":"6c3741e44c9b0ebd563c8c74dcfb2f593190dfd939266c07874dc093ecb4aa0e","impliedFormat":99},{"version":"e12cbccd28ee5c537fe59e3afdd55e6c9130a42d9d5bb4beb1c9da1d16f31680","impliedFormat":99},{"version":"16f4b05d144b4689870a3b12b4fae5e256d5b8108f360b4ac5add01956d5a86e","impliedFormat":99},{"version":"a65735a086ae8b401c1c41b51b41546532670c919fd2cedc1606fd186fcee2d7","impliedFormat":99},{"version":"fe021dbde66bd0d6195d4116dcb4c257966ebc8cfba0f34441839415e9e913e1","impliedFormat":99},{"version":"d52a4b1cabee2c94ed18c741c480a45dd9fed32477dd94a9cc8630a8bc263426","impliedFormat":99},{"version":"d059a52684789e6ef30f8052244cb7c52fb786e4066ac415c50642174cc76d14","impliedFormat":99},{"version":"addca1bb7478ebc3f1c67b710755acc945329875207a3c9befd6b5cbcab12574","impliedFormat":99},{"version":"50b565f4771b6b150cbf3ae31eb815c31f15e2e0f45518958a5f4348a1a01660","impliedFormat":99},{"version":"1453d1146382f9bcdf801cdcb5cadd9360c33a41d4be0f188bbaa01aa194ad72","impliedFormat":99},{"version":"842a0374c3dc4eac1c6f6cefeab3f1a7bc46f1ebaee6d13ddede169bce76367e","impliedFormat":99},{"version":"4056a596190daaaa7268f5465b972915facc5eca90ee6432e90afa130ba2e4ee","impliedFormat":99},{"version":"aa20728bb08af6288996197b97b5ed7bcfb0b183423bb482a9b25867a5b33c57","impliedFormat":99},{"version":"5322c3686d3797d415f8570eec54e898f328e59f8271b38516b1366074b499aa","impliedFormat":99},{"version":"b0aa778c53f491350d81ec58eb3e435d34bef2ec93b496c51d9b50aa5a8a61e5","impliedFormat":99},{"version":"fa454230c32f38213198cf47db147caf4c03920b3f8904566b29a1a033341602","impliedFormat":99},{"version":"5571608cd06d2935efe2ed7ba105ec93e5c5d1e822d300e5770a1ad9a065c8b6","impliedFormat":99},{"version":"6bf8aa6ed64228b4d065f334b8fe11bc11f59952fd15015b690dfb3301c94484","impliedFormat":99},{"version":"41ae2bf47844e4643ebe68b8e0019af7a87a9daea2d38959a9f7520ada9ad3cb","impliedFormat":99},{"version":"f4498a2ac4186466abe5f9641c9279a3458fa5992dc10ed4581c265469b118d4","impliedFormat":99},{"version":"bd09a0e906dae9a9351c658e7d8d6caa9f4df2ba104df650ebca96d1c4f81c23","impliedFormat":99},{"version":"055ad004f230e10cf1099d08c6f5774c564782bd76fbefbda669ab1ad132c175","impliedFormat":99},{"version":"55cc6faff37d64f21b0154469335e1918c7c9ed3531bd1bd09d0dab7ec3cb209","impliedFormat":99},{"version":"3a6888b7a0ce9a26de5fec6e4bf9401d0458f347098f524613cc4db8788d4d66","impliedFormat":99},{"version":"905b63e7c08e596aa40e0cda8a733d479ef49ce58c63b9bf534f544c47b86cf4","impliedFormat":99},{"version":"993bbf0cc9e59d3d09661a7851f22196ce93c3a12a4dc4116b424636c8548acb","impliedFormat":99},{"version":"11e5df7b2d205924b2c8fc6555b26a7e00bf6ad105b20aa9a0b5dea10aee0353","impliedFormat":99},{"version":"44b2bf4af6f235d90a59b7dd98d20de71d813742707d4cea38415afa6c895bed","impliedFormat":99},{"version":"9009e5bab1099a7a4427456b97185a606767294f330b248f6fd4bfaf47e58f1c","impliedFormat":99},{"version":"945ba40070b4fc0a0e288e4ee3d23327d9d972f5f364454409e8f0bf97d48327","impliedFormat":99},{"version":"2566008927db5ac0be2b1dc7b031c221914669f821ce2b45094b44e74f7c240e","impliedFormat":99},{"version":"fbe707d31a1bf214337e9254de51d4cd92c0fc22e691ef8c5b9d75ed03445d6d","impliedFormat":99},{"version":"2a51fce1374a33b8398940466d31be982f400ea80fc4298e240cfa8d88394329","impliedFormat":99},{"version":"5d96fcc2abaff5db2621ba566d3356ee7f437107d62d8310e78c172c30aef5a4","impliedFormat":99},{"version":"3b1df7d07b7f9dda15a8e222b3b62a575542c968e24f55e1164c9508b0545142","impliedFormat":99},{"version":"77cbb914aa1f4ea32578ac8a790ede70eee91a44c56e696634ba54741fb1473b","impliedFormat":99},{"version":"c634f41de3b9541389c42940cbff292972efdeef2bb0f1415bf0ea5fa4115d0a","impliedFormat":99},{"version":"ccc8a5cc2dd937455fadc569b95371081e2d3f15fc5201d475c208e52dbaf2c5","impliedFormat":99},{"version":"c1a2e05eb6d7ca8d7e4a7f4c93ccf0c2857e842a64c98eaee4d85841ee9855e6","impliedFormat":1},{"version":"835fb2909ce458740fb4a49fc61709896c6864f5ce3db7f0a88f06c720d74d02","impliedFormat":1},{"version":"6e5857f38aa297a859cab4ec891408659218a5a2610cd317b6dcbef9979459cc","impliedFormat":1},{"version":"ead8e39c2e11891f286b06ae2aa71f208b1802661fcdb2425cffa4f494a68854","impliedFormat":1},{"version":"82919acbb38870fcf5786ec1292f0f5afe490f9b3060123e48675831bd947192","impliedFormat":1},{"version":"e222701788ec77bd57c28facbbd142eadf5c749a74d586bc2f317db7e33544b1","impliedFormat":1},{"version":"09154713fae0ed7befacdad783e5bd1970c06fc41a5f866f7f933b96312ce764","impliedFormat":1},{"version":"8d67b13da77316a8a2fabc21d340866ddf8a4b99e76a6c951cc45189142df652","impliedFormat":1},{"version":"a91c8d28d10fee7fe717ddf3743f287b68770c813c98f796b6e38d5d164bd459","impliedFormat":1},{"version":"68add36d9632bc096d7245d24d6b0b8ad5f125183016102a3dad4c9c2438ccb0","impliedFormat":1},{"version":"3a819c2928ee06bbcc84e2797fd3558ae2ebb7e0ed8d87f71732fb2e2acc87b4","impliedFormat":1},{"version":"f6f827cd43e92685f194002d6b52a9408309cda1cec46fb7ca8489a95cbd2fd4","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"e0bfe601a9fdf6defe94ed62dc60ac71597566001a1f86e705c95e431a9c816d","impliedFormat":1},{"version":"a270a1a893d1aee5a3c1c8c276cd2778aa970a2741ee2ccf29cc3210d7da80f5","impliedFormat":1},{"version":"add0ce7b77ba5b308492fa68f77f24d1ed1d9148534bdf05ac17c30763fc1a79","impliedFormat":1},{"version":"8926594ee895917e90701d8cbb5fdf77fc238b266ac540f929c7253f8ad6233d","impliedFormat":1},{"version":"2f67911e4bf4e0717dc2ded248ce2d5e4398d945ee13889a6852c1233ea41508","impliedFormat":1},{"version":"d8430c275b0f59417ea8e173cfb888a4477b430ec35b595bf734f3ec7a7d729f","impliedFormat":1},{"version":"69364df1c776372d7df1fb46a6cb3a6bf7f55e700f533a104e3f9d70a32bec18","impliedFormat":1},{"version":"6042774c61ece4ba77b3bf375f15942eb054675b7957882a00c22c0e4fe5865c","impliedFormat":1},{"version":"5a3bd57ed7a9d9afef74c75f77fce79ba3c786401af9810cdf45907c4e93f30e","impliedFormat":1},{"version":"ed8763205f02fb65e84eff7432155258df7f93b7d938f01785cb447d043d53f3","impliedFormat":1},{"version":"30db853bb2e60170ba11e39ab48bacecb32d06d4def89eedf17e58ebab762a65","impliedFormat":1},{"version":"e27451b24234dfed45f6cf22112a04955183a99c42a2691fb4936d63cfe42761","impliedFormat":1},{"version":"2316301dd223d31962d917999acf8e543e0119c5d24ec984c9f22cb23247160c","impliedFormat":1},{"version":"58d65a2803c3b6629b0e18c8bf1bc883a686fcf0333230dd0151ab6e85b74307","impliedFormat":1},{"version":"e818471014c77c103330aee11f00a7a00b37b35500b53ea6f337aefacd6174c9","impliedFormat":1},{"version":"d4a5b1d2ff02c37643e18db302488cd64c342b00e2786e65caac4e12bda9219b","impliedFormat":1},{"version":"29f823cbe0166e10e7176a94afe609a24b9e5af3858628c541ff8ce1727023cd","impliedFormat":1},{"version":"d3a6e1ff56a0a760b1b36eb34925042a8148d8e0ab4c2d28fcbdbf40fb5a8acc","impliedFormat":99},{"version":"839114ddc4236b8304df6a6c7cc6784913b7c69a63afd4ecb36fc5c9c57276fc","impliedFormat":99},{"version":"cc4ecb6238b32248c6b58577a2ac2a6223c002c1a9e3f1f9424a89f44aa84f0a","impliedFormat":99},{"version":"b1e7a09644f81b1f6445e1cd103ccb6af6abf5455c2b0061fb2f1505814445ac","impliedFormat":99},{"version":"73caab25ef0e9cac9fa83c39527035b96814443ba3e1081fcf02e28e9ff5d063","impliedFormat":99},{"version":"eed6f8e5ba72c1c2327643aba311bf83968530aea2713ffcc64a7328be42cb7b","impliedFormat":99},{"version":"d9fdc24b918caacab2e2954171d4aa453a810ba8fe637c9c77eb9c82e6e5c9b5","impliedFormat":99},{"version":"9621061af7d41f0fed10cc9c5c610f7a1297d0a80e77c41995af26d1d875775d","impliedFormat":99},{"version":"fe0e2758a8a8b3dc3c23acd450df8dd510e0ac4ee79970af52301fb9307ef621","impliedFormat":99},{"version":"ae8ea796987c2692c6d1847cb16b2ca3ec84d519a4426e7f90e0cd6b3a57d76b","impliedFormat":99},{"version":"da5403deff1164f4917c1f71ab3365853401acf83379abfa8b0e4c219e3b5b9c","impliedFormat":99},{"version":"21f8774a90e6e2c997ac1b165f8c375d46921a589cf1117ce8b63a3da7317208","impliedFormat":99},{"version":"4de612234aee194b3cc9f1998499db2f9447309331428e534524cb057e78642c","signature":"daf6a0b10aca8681e68361b871f02b422d68e7a555ee039ce058067df6c0784a"},{"version":"bb02a8958047c58998585e91c248a80b2a2ca7c2f3d5416daca6e78a145bf507","impliedFormat":1},{"version":"0170ed5ba2e3b3497af9878c5d48c58f815d4429aaad9fc0bce6602ad03870df","impliedFormat":99},{"version":"89e6cb3a9555b92f531ea6222eea4a40be054a9f58bcf8b24c63c2ffb7bab0c9","signature":"dce1a087265c9161efd328f43ed9ea761438ef9683d906a8b9f7751b2c9527a7"},{"version":"fa8fd0c1b380b525c38216ac8f296402d460100942bb7f5cc32494d2cd9e6b06","signature":"75fc791929321fba6ba7a3276ccd4c2b6560bc47e9ca1aaa457afeae13b8f5c6"},{"version":"168bf3629d87ea4d9c947b4454b6b47c6ae646e697676f194e55cfe9fea29deb","impliedFormat":99},{"version":"445749eb9eaba85286f2329f7b043fbbaef2567f49cb89dab7d1ad941c506d8d","impliedFormat":99},{"version":"909e4aba623c453e3effe342f0d79c5fe0b4c0653004a8db316b3a3f53a6b7d3","impliedFormat":99},{"version":"0b2d6655ae1c1b1f8f825eb397d758a0b24b8a556dd03d3ad001e884f2171488","impliedFormat":99},{"version":"5105bbffcc9bf47e1de2380df13cd334b36fc3a582427826f40c8f627320ae16","impliedFormat":99},{"version":"adfda365e7a7b3f6b6c4909d8dd3a00c133bb67ad925204c358702b32e2351ee","impliedFormat":99},{"version":"c2ba6dea9b641828281fad5dbcca7ed9ea263b2fae1d90ab98304f9582bb589e","impliedFormat":99},{"version":"622c3617ec1da0036d7a68ac30c01fa401791a320898ea866c29ae49a293f4db","impliedFormat":99},{"version":"6cc0caaae7b01ddd72c9c36ab12827b6a1e4ed722e4433d7d3f8903ed31645ae","impliedFormat":99},{"version":"bd98c5555dd88b9f9686e5711cd3b42380a1958eeb4d5aee8249c4b84393471e","impliedFormat":99},{"version":"f431f0000f3ff27dd8824931dbf17c8bc68ee2551eec691f2812bae39bb3ce45","impliedFormat":99},{"version":"4e1deafdca057e04cc798f8c6bdecec1b624d1c2df1591848663939daffbd675","impliedFormat":99},{"version":"1fc64c6bda09d1f250436c0690262eeab55f803d1abc2b7e0620ef8cc1500087","impliedFormat":99},{"version":"0a76797de90647d13ddae055b94bebb9aaa24f96fdfb3c99556677652e98167b","impliedFormat":99},{"version":"a0cc536a85dca0de44d25c2c704e4e4009f6b6eabe3ae1e965c886529a6f8341","impliedFormat":99},{"version":"06d6a979c4cac9fe223b5fb768fc6c7c31586308a1360a6edf5971afe129faab","impliedFormat":99},{"version":"432904aba95c74008e841a949b01d39e84915e5b9fc58a075894d802fa60dace","impliedFormat":99},{"version":"0cacc9158e69c16f24cf7f4d7497ddc0bb641b39c085bd5d93963ac218a185fc","impliedFormat":99},{"version":"19005244c31b94d94c872d226b684c11143f554f772e36b09f4aa9159192be74","impliedFormat":99},{"version":"94a003d5819de12cbd240a778c4b8543c8f693d85468223a2cc6878be18cf81f","impliedFormat":99},{"version":"c098ecfc6f67903ac2a51b9aae5b64cd04672fa7f194032e44efdff4e435ca2c","impliedFormat":99},{"version":"8d56dc11df16222ec8f72002d31b40bef472a96a73d713d760c4b3927787f8c6","impliedFormat":99},{"version":"4d46e4bc399668902131af2af99959f735e5d664687b05a8b85ce6684b1a81e5","impliedFormat":99},{"version":"77d7913479c9ce4ea7a4a31eb4d82a7f5059bed39ba4b05386784bbe96da88c4","impliedFormat":99},{"version":"888d7eb20ac16f2d61ebca87a6c6366c9be236356cf207d25814f60b141ca822","impliedFormat":99},{"version":"a7775e809c78f34e318f3b556f6b48986fcacae382363de053d22531315019b9","impliedFormat":99},{"version":"e7b0aaf53c4aeb8777bf07cc717341b8c248243e4eac281fbff9b1b9b5b30609","impliedFormat":99},{"version":"9aee2824983bbea0aed365194e957a4e7e596927a05a7a0a189ed259baa0b615","impliedFormat":99},{"version":"723c8d96de2952aa6400859bc69a54995bea3cfcf1cde9ea273a491e055d76e0","impliedFormat":99},{"version":"416f76fcfa73d480084832cce6748a0bab9aaac25b13b8d941efe8922c26165d","impliedFormat":99},{"version":"9b1e0b3ef3bfd4dd83832298a45d21f36363f789ec62ceeda33b6174bdd0ed76","impliedFormat":99},{"version":"629a5b84340b49c75877efe93e5411f8e0d30457e2e30d441d6eaeccf7bf48c5","impliedFormat":99},{"version":"8a63950706b3422f381afc99a2a271367bcc7886826804c16b9de9035dba1dc5","impliedFormat":99},{"version":"a94c6ab42fda4fc3b59e5e25e89391fa6e795fd5ba8b8db80da20e52cea1307d","impliedFormat":99},{"version":"dbf537dc9a40af4eb3b7970c7aec3486ac65357ee750b328605b76dd0d73662a","signature":"81581e91744769c6f2dfe013318fa82f32cff0954f715a191a422dbac560aa8f"},{"version":"6c05d0fcee91437571513c404e62396ee798ff37a2d8bef2104accdc79deb9c0","impliedFormat":1},{"version":"a9373d52584b48809ffd61d74f5b3dfd127da846e3c4ee3c415560386df3994b","impliedFormat":99},{"version":"caf4af98bf464ad3e10c46cf7d340556f89197aab0f87f032c7b84eb8ddb24d9","impliedFormat":99},{"version":"7ec047b73f621c526468517fea779fec2007dd05baa880989def59126c98ef79","impliedFormat":99},{"version":"8dd450de6d756cee0761f277c6dc58b0b5a66b8c274b980949318b8cad26d712","impliedFormat":99},{"version":"904d6ad970b6bd825449480488a73d9b98432357ab38cf8d31ffd651ae376ff5","impliedFormat":99},{"version":"dfcf16e716338e9fe8cf790ac7756f61c85b83b699861df970661e97bf482692","impliedFormat":99},{"version":"f3621142447482ed13dd7d468743f33252328453d6a61ff032b011ee48535957","impliedFormat":99},{"version":"fff5b0ae589e75961162f3dc50bf1115d808f7d4e04dbd7d5081f99b437d101b","impliedFormat":99},{"version":"2f90c30dcd9d485862a1a6816851fba71dfaad8626108b3a385023d3f3f3819d","impliedFormat":99},{"version":"a19016a2b1018356e27889ab01b32358e0168229b53f04deecd44866a71f165e","impliedFormat":99},{"version":"b5f7d776c8920b3fb4b2ee16d18b6b2516ea19a4108f6e87b5f585e9576281c0","impliedFormat":99},{"version":"3677988e03b749874eb9c1aa8dc88cd77b6005e5c4c39d821cda7b80d5388619","impliedFormat":1},{"version":"ff863d17c6c659440f7c5c536e4db7762d8c2565547b2608f36b798a743606ca","impliedFormat":1},{"version":"9e59e4e82e2f8136ff84c322b80a4c3c0edbae212000449f8d27f0ee6f9bad1b","impliedFormat":99},{"version":"e15ebce84f4c117f38aa2b2978eb5cc4cd6196c165c7334c7c926d14a2e4fc2b","impliedFormat":99},{"version":"919059821b88ddc0f60e89fb1b8415a09218cfc808a8d5a574a8c62716b63674","signature":"bd0ddc78128b4289fe5e39e1b44f80442ec25c5f5261b6c15770348447c124b9"},{"version":"3b4c93da713d16dc92af11b46efc260ace64eda03f76dc2a0ccc3f9cee187a13","impliedFormat":99},{"version":"4aba443be88627c25a3b0e8cffd32bbb0b650dbcd71b5f5c5fd2d8d7cab36663","impliedFormat":99},{"version":"99d1a601593495371e798da1850b52877bf63d0678f15722d5f048e404f002e4","impliedFormat":99},{"version":"0943a6e4e026d0de8a4969ee975a7283e0627bf41aa4635d8502f6f24365ac9b","impliedFormat":99},{"version":"22399c4e9eede1c6b716dd1a29e3e1a58f2cf8404cf57e7f33dd0730c9ab1766","impliedFormat":99},{"version":"db74b180e4b11e4a97ebf070a2872d60cafbed9532817d05fbb1e3fdfc035316","impliedFormat":99},{"version":"32f424d43980d7e1cc85811ee013573140eeefe818fd7b13f6363b694f25c8e5","impliedFormat":99},{"version":"d6c76b0ddf1fa28d2854badf47752b9dec1933a2bb4f69553bfdf21d6ca7ed12","impliedFormat":99},{"version":"6da77e8d67ae57842ca65fa27d2a24632f5a16287d535ed254edd8925c4a74f0","impliedFormat":99},{"version":"c2193977365638d6b7188080cea173dfe0e967d8d7c68c28941ca46393937e35","impliedFormat":99},{"version":"c4f238e3ed1957e6f8ebaba7b384b563438acc39e04fb27234a11c78226c190a","impliedFormat":99},{"version":"686cdf732d387f9035e2dcc87467f27513382ff6bc972c9d6d6540f05fe47ce6","impliedFormat":99},{"version":"5412ad0043cd60d1f1406fc12cb4fb987e9a734decbdd4db6f6acf71791e36fe","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"e110dcbf457ae2911799895b76e8478d6502ac85742b059184fb90ec2a55ba3f","signature":"7f38e519eb16ce4ce8d09c37b38d5fc8818316ab6a477c085ec80b7ee8737674"},{"version":"ebaa7157364a7a2bd6e4d9d538c3e104ac2d1143d39d2a67281729d52da24e11","signature":"6b9fbf41229dc7ca3f98ae99ddc9ed4a9472680b244deae0bca620e81314ddc8"},{"version":"9a8098210fe09d5a0d6f8351789e033a9d7525dfe272cf89ae0e34200986ee4d","impliedFormat":99},{"version":"f38918a703d8cbba6cfa9cc0ca34ba7851fa09e6be5498428112806135e7a824","impliedFormat":99},{"version":"e994c2a0c24a5c9fb0f838d4be75f6b108a71a4716d1616a208c27abfe00b761","impliedFormat":99},{"version":"e5a1c7d86d7462f04a9d032fa8572305015632229fdfc5713be07cbc446cba25","impliedFormat":99},{"version":"21da358700a3893281ce0c517a7a30cbd46be020d9f0c3f2834d0a8ad1f5fc75","impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"378281aa35786c27d5811af7e6bcaa492eebd0c7013d48137c35bbc69a2b9751","affectsGlobalScope":true,"impliedFormat":1},{"version":"3af97acf03cc97de58a3a4bc91f8f616408099bc4233f6d0852e72a8ffb91ac9","affectsGlobalScope":true,"impliedFormat":1},{"version":"1b2dd1cbeb0cc6ae20795958ba5950395ebb2849b7c8326853dd15530c77ab0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1db0b7dca579049ca4193d034d835f6bfe73096c73663e5ef9a0b5779939f3d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"387a023d363f755eb63450a66c28b14cdd7bc30a104565e2dbf0a8988bb4a56c","affectsGlobalScope":true,"impliedFormat":1},{"version":"9798340ffb0d067d69b1ae5b32faa17ab31b82466a3fc00d8f2f2df0c8554aaa","affectsGlobalScope":true,"impliedFormat":1},{"version":"f26b11d8d8e4b8028f1c7d618b22274c892e4b0ef5b3678a8ccbad85419aef43","affectsGlobalScope":true,"impliedFormat":1},{"version":"cdcf9ea426ad970f96ac930cd176d5c69c6c24eebd9fc580e1572d6c6a88f62c","impliedFormat":1},{"version":"23cd712e2ce083d68afe69224587438e5914b457b8acf87073c22494d706a3d0","impliedFormat":1},{"version":"487b694c3de27ddf4ad107d4007ad304d29effccf9800c8ae23c2093638d906a","impliedFormat":1},{"version":"3a80bc85f38526ca3b08007ee80712e7bb0601df178b23fbf0bf87036fce40ce","impliedFormat":1},{"version":"ccf4552357ce3c159ef75f0f0114e80401702228f1898bdc9402214c9499e8c0","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"68834d631c8838c715f225509cfc3927913b9cc7a4870460b5b60c8dbdb99baf","impliedFormat":1},{"version":"2931540c47ee0ff8a62860e61782eb17b155615db61e36986e54645ec67f67c2","impliedFormat":1},{"version":"ccab02f3920fc75c01174c47fcf67882a11daf16baf9e81701d0a94636e94556","impliedFormat":1},{"version":"f6faf5f74e4c4cc309a6c6a6c4da02dbb840be5d3e92905a23dcd7b2b0bd1986","impliedFormat":1},{"version":"ea6bc8de8b59f90a7a3960005fd01988f98fd0784e14bc6922dde2e93305ec7d","impliedFormat":1},{"version":"36107995674b29284a115e21a0618c4c2751b32a8766dd4cb3ba740308b16d59","impliedFormat":1},{"version":"914a0ae30d96d71915fc519ccb4efbf2b62c0ddfb3a3fc6129151076bc01dc60","impliedFormat":1},{"version":"33e981bf6376e939f99bd7f89abec757c64897d33c005036b9a10d9587d80187","impliedFormat":1},{"version":"7fd1b31fd35876b0aa650811c25ec2c97a3c6387e5473eb18004bed86cdd76b6","impliedFormat":1},{"version":"b41767d372275c154c7ea6c9d5449d9a741b8ce080f640155cc88ba1763e35b3","impliedFormat":1},{"version":"3bacf516d686d08682751a3bd2519ea3b8041a164bfb4f1d35728993e70a2426","impliedFormat":1},{"version":"7fb266686238369442bd1719bc0d7edd0199da4fb8540354e1ff7f16669b4323","impliedFormat":1},{"version":"0a60a292b89ca7218b8616f78e5bbd1c96b87e048849469cccb4355e98af959a","impliedFormat":1},{"version":"0b6e25234b4eec6ed96ab138d96eb70b135690d7dd01f3dd8a8ab291c35a683a","impliedFormat":1},{"version":"9666f2f84b985b62400d2e5ab0adae9ff44de9b2a34803c2c5bd3c8325b17dc0","impliedFormat":1},{"version":"40cd35c95e9cf22cfa5bd84e96408b6fcbca55295f4ff822390abb11afbc3dca","impliedFormat":1},{"version":"b1616b8959bf557feb16369c6124a97a0e74ed6f49d1df73bb4b9ddf68acf3f3","impliedFormat":1},{"version":"5b03a034c72146b61573aab280f295b015b9168470f2df05f6080a2122f9b4df","impliedFormat":1},{"version":"40b463c6766ca1b689bfcc46d26b5e295954f32ad43e37ee6953c0a677e4ae2b","impliedFormat":1},{"version":"249b9cab7f5d628b71308c7d9bb0a808b50b091e640ba3ed6e2d0516f4a8d91d","impliedFormat":1},{"version":"80aae6afc67faa5ac0b32b5b8bc8cc9f7fa299cff15cf09cc2e11fd28c6ae29e","impliedFormat":1},{"version":"f473cd2288991ff3221165dcf73cd5d24da30391f87e85b3dd4d0450c787a391","impliedFormat":1},{"version":"499e5b055a5aba1e1998f7311a6c441a369831c70905cc565ceac93c28083d53","impliedFormat":1},{"version":"54c3e2371e3d016469ad959697fd257e5621e16296fa67082c2575d0bf8eced0","impliedFormat":1},{"version":"beb8233b2c220cfa0feea31fbe9218d89fa02faa81ef744be8dce5acb89bb1fd","impliedFormat":1},{"version":"c183b931b68ad184bc8e8372bf663f3d33304772fb482f29fb91b3c391031f3e","impliedFormat":1},{"version":"5d0375ca7310efb77e3ef18d068d53784faf62705e0ad04569597ae0e755c401","impliedFormat":1},{"version":"59af37caec41ecf7b2e76059c9672a49e682c1a2aa6f9d7dc78878f53aa284d6","impliedFormat":1},{"version":"addf417b9eb3f938fddf8d81e96393a165e4be0d4a8b6402292f9c634b1cb00d","impliedFormat":1},{"version":"48cc3ec153b50985fb95153258a710782b25975b10dd4ac8a4f3920632d10790","impliedFormat":1},{"version":"adf27937dba6af9f08a68c5b1d3fce0ca7d4b960c57e6d6c844e7d1a8e53adae","impliedFormat":1},{"version":"e1528ca65ac90f6fa0e4a247eb656b4263c470bb22d9033e466463e13395e599","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"866078923a56d026e39243b4392e282c1c63159723996fa89243140e1388a98d","impliedFormat":1},{"version":"f724236417941ea77ec8d38c6b7021f5fb7f8521c7f8c1538e87661f2c6a0774","affectsGlobalScope":true,"impliedFormat":1},{"version":"1cf059eaf468efcc649f8cf6075d3cb98e9a35a0fe9c44419ec3d2f5428d7123","affectsGlobalScope":true,"impliedFormat":1},{"version":"e7721c4f69f93c91360c26a0a84ee885997d748237ef78ef665b153e622b36c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"d97fb21da858fb18b8ae72c314e9743fd52f73ebe2764e12af1db32fc03f853f","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ea15fd99b2e34cb25fe8346c955000bb70c8b423ae4377a972ef46bfb37f595","impliedFormat":1},{"version":"7cf69dd5502c41644c9e5106210b5da7144800670cbe861f66726fa209e231c4","impliedFormat":1},{"version":"72c1f5e0a28e473026074817561d1bc9647909cf253c8d56c41d1df8d95b85f7","impliedFormat":1},{"version":"f9b4137a0d285bd77dba2e6e895530112264310ae47e07bf311feae428fb8b61","affectsGlobalScope":true,"impliedFormat":1},{"version":"8b21e13ed07d0df176ae31d6b7f01f7b17d66dbeb489c0d31d00de2ca14883da","impliedFormat":1},{"version":"51aecd2df90a3cffea1eb4696b33b2d78594ea2aa2138e6b9471ec4841c6c2ee","impliedFormat":1},{"version":"9d8f9e63e29a3396285620908e7f14d874d066caea747dc4b2c378f0599166b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"f929f0b6b3421a2d34344b0f421f45aeb2c84ad365ebf29d04312023b3accc58","impliedFormat":1},{"version":"db9ada976f9e52e13f7ae8b9a320f4b67b87685938c5879187d8864b2fbe97f3","impliedFormat":1},{"version":"9f39e70a354d0fba29ac3cdf6eca00b7f9e96f64b2b2780c432e8ea27f133743","impliedFormat":1},{"version":"0dace96cc0f7bc6d0ee2044921bdf19fe42d16284dbcc8ae200800d1c9579335","impliedFormat":1},{"version":"a2e2bbde231b65c53c764c12313897ffdfb6c49183dd31823ee2405f2f7b5378","impliedFormat":1},{"version":"ad1cc0ed328f3f708771272021be61ab146b32ecf2b78f3224959ff1e2cd2a5c","impliedFormat":1},{"version":"c64e1888baaa3253ca4405b455e4bf44f76357868a1bd0a52998ade9a092ad78","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc8c6f5322961b56d9906601b20798725df60baeab45ec014fba9f795d5596fd","impliedFormat":1},{"version":"0904660ae854e6d41f6ff25356db1d654436c6305b0f0aa89d1532df0253486e","impliedFormat":1},{"version":"9cdfd0a77dd7eeed57e91d3f449274ea2470abdb7e167a2f146b1ea8de6224e0","impliedFormat":1},{"version":"230bdc111d7578276e4a3bb9d075d85c78c6b68f428c3a9935e2eaa10f4ae1f5","impliedFormat":1},{"version":"e8aabbee5e7b9101b03bb4222607d57f38859b8115a8050a4eb91b4ee43a3a73","impliedFormat":1},{"version":"bbf42f98a5819f4f06e18c8b669a994afe9a17fe520ae3454a195e6eabf7700d","impliedFormat":1},{"version":"c0bb1b65757c72bbf8ddf7eaa532223bacf58041ff16c883e76f45506596e925","impliedFormat":1},{"version":"c8b85f7aed29f8f52b813f800611406b0bfe5cf3224d20a4bdda7c7f73ce368e","affectsGlobalScope":true,"impliedFormat":1},{"version":"145dcf25fd4967c610c53d93d7bc4dce8fbb1b6dd7935362472d4ae49363c7ba","impliedFormat":1},{"version":"ff65b8a8bd380c6d129becc35de02f7c29ad7ce03300331ca91311fb4044d1a9","impliedFormat":1},{"version":"04bf1aa481d1adfb16d93d76e44ce71c51c8ef68039d849926551199489637f6","impliedFormat":1},{"version":"9043daec15206650fa119bad6b8d70136021ea7d52673a71f79a87a42ee38d44","affectsGlobalScope":true,"impliedFormat":1},{"version":"d00e86e2e74089bf416b4c5cc433d88eb2e09dcef5e3c5b79ca04a36d8d8d6f5","affectsGlobalScope":true,"impliedFormat":1},{"version":"a58a15da4c5ba3df60c910a043281256fa52d36a0fcdef9b9100c646282e88dd","impliedFormat":1},{"version":"b36beffbf8acdc3ebc58c8bb4b75574b31a2169869c70fc03f82895b93950a12","impliedFormat":1},{"version":"de263f0089aefbfd73c89562fb7254a7468b1f33b61839aafc3f035d60766cb4","impliedFormat":1},{"version":"77fbe5eecb6fac4b6242bbf6eebfc43e98ce5ccba8fa44e0ef6a95c945ff4d98","impliedFormat":1},{"version":"8c81fd4a110490c43d7c578e8c6f69b3af01717189196899a6a44f93daa57a3a","impliedFormat":1},{"version":"5fb39858b2459864b139950a09adae4f38dad87c25bf572ce414f10e4bd7baab","impliedFormat":1},{"version":"65faec1b4bd63564aeec33eab9cacfaefd84ce2400f03903a71a1841fbce195f","impliedFormat":1},{"version":"b33b74b97952d9bf4fbd2951dcfbb5136656ddb310ce1c84518aaa77dbca9992","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"45650f47bfb376c8a8ed39d4bcda5902ab899a3150029684ee4c10676d9fbaee","impliedFormat":1},{"version":"8d117798e5228c7fdff887f44851d07320739c5cc0d511afae8f250c51809a36","affectsGlobalScope":true,"impliedFormat":1},{"version":"c119835edf36415081dfd9ed15fc0cd37aaa28d232be029ad073f15f3d88c323","impliedFormat":1},{"version":"8e7c3bed5f19ade8f911677ddc83052e2283e25b0a8654cd89db9079d4b323c7","impliedFormat":1},{"version":"9705cd157ffbb91c5cab48bdd2de5a437a372e63f870f8a8472e72ff634d47c1","affectsGlobalScope":true,"impliedFormat":1},{"version":"ae86f30d5d10e4f75ce8dcb6e1bd3a12ecec3d071a21e8f462c5c85c678efb41","impliedFormat":1},{"version":"a1a3cbade20430dcb7f00fa23c2f020e827d5620c0d44213db1665c53231f1fc","impliedFormat":1},{"version":"e03460fe72b259f6d25ad029f085e4bedc3f90477da4401d8fbc1efa9793230e","impliedFormat":1},{"version":"4286a3a6619514fca656089aee160bb6f2e77f4dd53dc5a96b26a0b4fc778055","impliedFormat":1},{"version":"69e0a41d620fb678a899c65e073413b452f4db321b858fe422ad93fd686cd49a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3585d6891e9ea18e07d0755a6d90d71331558ba5dc5561933553209f886db106","affectsGlobalScope":true,"impliedFormat":1},{"version":"86be71cbb0593468644932a6eb96d527cfa600cecfc0b698af5f52e51804451d","impliedFormat":1},{"version":"84dd6b0fd2505135692935599d6606f50a421389e8d4535194bcded307ee5cf2","impliedFormat":1},{"version":"0d5b085f36e6dc55bc6332ecb9c733be3a534958c238fb8d8d18d4a2b6f2a15a","impliedFormat":1},{"version":"db19ea066fdc5f97df3f769e582ae3000380ab7942e266654bdb1a4650d19eaf","affectsGlobalScope":true,"impliedFormat":1},{"version":"2a034894bf28c220a331c7a0229d33564803abe2ac1b9a5feee91b6b9b6e88ea","impliedFormat":1},{"version":"d7e9ab1b0996639047c61c1e62f85c620e4382206b3abb430d9a21fb7bc23c77","impliedFormat":1},{"version":"2beff543f6e9a9701df88daeee3cdd70a34b4a1c11cb4c734472195a5cb2af54","impliedFormat":1},{"version":"2e07abf27aa06353d46f4448c0bbac73431f6065eef7113128a5cd804d0c384d","impliedFormat":1},{"version":"be1cc4d94ea60cbe567bc29ed479d42587bf1e6cba490f123d329976b0fe4ee5","impliedFormat":1},{"version":"42bc0e1a903408137c3df2b06dfd7e402cdab5bbfa5fcfb871b22ebfdb30bd0b","impliedFormat":1},{"version":"9894dafe342b976d251aac58e616ac6df8db91fb9d98934ff9dd103e9e82578f","impliedFormat":1},{"version":"413df52d4ea14472c2fa5bee62f7a40abd1eb49be0b9722ee01ee4e52e63beb2","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"446a50749b24d14deac6f8843e057a6355dd6437d1fac4f9e5ce4a5071f34bff","impliedFormat":1},{"version":"182e9fcbe08ac7c012e0a6e2b5798b4352470be29a64fdc114d23c2bab7d5106","impliedFormat":1},{"version":"5c9b31919ea1cb350a7ae5e71c9ced8f11723e4fa258a8cc8d16ae46edd623c7","impliedFormat":1},{"version":"4aa42ce8383b45823b3a1d3811c0fdd5f939f90254bc4874124393febbaf89f6","impliedFormat":1},{"version":"96ffa70b486207241c0fcedb5d9553684f7fa6746bc2b04c519e7ebf41a51205","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"ad0d1d75d129b1c80f911be438d6b61bfa8703930a8ff2be2f0e1f8a91841c64","impliedFormat":1},{"version":"3e7efde639c6a6c3edb9847b3f61e308bf7a69685b92f665048c45132f51c218","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"8a0e762ceb20c7e72504feef83d709468a70af4abccb304f32d6b9bac1129b2c","impliedFormat":1},{"version":"7cb0ee103671d1e201cd53dda12bc1cd0a35f1c63d6102720c6eeb322cb8e17e","impliedFormat":1},{"version":"ce75b1aebb33d510ff28af960a9221410a3eaf7f18fc5f21f9404075fba77256","impliedFormat":1},{"version":"ee8df1cb8d0faaca4013a1b442e99130769ce06f438d18d510fed95890067563","impliedFormat":1},{"version":"6f491d0108927478d3247bbbc489c78c2da7ef552fd5277f1ab6819986fdf0b1","impliedFormat":1},{"version":"594fe24fc54645ab6ccb9dba15d3a35963a73a395b2ef0375ea34bf181ccfd63","impliedFormat":1},{"version":"f4625edcb57b37b84506e8b276eb59ca30d31f88c6656d29d4e90e3bc58e69df","impliedFormat":1},{"version":"15a234e5031b19c48a69ccc1607522d6e4b50f57d308ecb7fe863d44cd9f9eb3","impliedFormat":1},{"version":"bfb7f8475428637bee12bdd31bd9968c1c8a1cc2c3e426c959e2f3a307f8936f","impliedFormat":1},{"version":"7580e62139cb2b44a0270c8d01abcbfcba2819a02514a527342447fa69b34ef1","impliedFormat":1},{"version":"f374cb24e93e7798c4d9e83ff872fa52d2cdb36306392b840a6ddf46cb925cb6","impliedFormat":1},{"version":"6b3453eebd474cc8acf6d759f1668e6ce7425a565e2996a20b644c72916ecf75","impliedFormat":1},{"version":"7e6ac205dcb9714f708354fd863bffa45cee90740706cc64b3b39b23ebb84744","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"148679c6d0f449210a96e7d2e562d589e56fcde87f843a92808b3ff103f1a774","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"02436d7e9ead85e09a2f8e27d5f47d9464bced31738dec138ca735390815c9f0","impliedFormat":1},{"version":"78a2869ad0cbf3f9045dda08c0d4562b7e1b2bfe07b19e0db072f5c3c56e9584","impliedFormat":1},{"version":"f8d5ff8eafd37499f2b6a98659dd9b45a321de186b8db6b6142faed0fea3de77","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"c685d9f68c70fe11ce527287526585a06ea13920bb6c18482ca84945a4e433a7","impliedFormat":1},{"version":"540cc83ab772a2c6bc509fe1354f314825b5dba3669efdfbe4693ecd3048e34f","impliedFormat":1},{"version":"121b0696021ab885c570bbeb331be8ad82c6efe2f3b93a6e63874901bebc13e3","impliedFormat":1},{"version":"4e01846df98d478a2a626ec3641524964b38acaac13945c2db198bf9f3df22ee","impliedFormat":1},{"version":"678d6d4c43e5728bf66e92fc2269da9fa709cb60510fed988a27161473c3853f","impliedFormat":1},{"version":"ffa495b17a5ef1d0399586b590bd281056cee6ce3583e34f39926f8dcc6ecdb5","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"aa14cee20aa0db79f8df101fc027d929aec10feb5b8a8da3b9af3895d05b7ba2","impliedFormat":1},{"version":"493c700ac3bd317177b2eb913805c87fe60d4e8af4fb39c41f04ba81fae7e170","impliedFormat":1},{"version":"aeb554d876c6b8c818da2e118d8b11e1e559adbe6bf606cc9a611c1b6c09f670","impliedFormat":1},{"version":"acf5a2ac47b59ca07afa9abbd2b31d001bf7448b041927befae2ea5b1951d9f9","impliedFormat":1},{"version":"8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881","impliedFormat":1},{"version":"d71291eff1e19d8762a908ba947e891af44749f3a2cbc5bd2ec4b72f72ea795f","impliedFormat":1},{"version":"c0480e03db4b816dff2682b347c95f2177699525c54e7e6f6aa8ded890b76be7","impliedFormat":1},{"version":"e2a37ac938c4bede5bb284b9d2d042da299528f1e61f6f57538f1bd37d760869","impliedFormat":1},{"version":"76def37aff8e3a051cf406e10340ffba0f28b6991c5d987474cc11137796e1eb","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"2652448ac55a2010a1f71dd141f828b682298d39728f9871e1cdf8696ef443fd","impliedFormat":1},{"version":"d682336018141807fb602709e2d95a192828fcb8d5ba06dda3833a8ea98f69e3","impliedFormat":1},{"version":"6124e973eab8c52cabf3c07575204efc1784aca6b0a30c79eb85fe240a857efa","impliedFormat":1},{"version":"0d891735a21edc75df51f3eb995e18149e119d1ce22fd40db2b260c5960b914e","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"4fbd3116e00ed3a6410499924b6403cc9367fdca303e34838129b328058ede40","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"2f9c89cbb29d362290531b48880a4024f258c6033aaeb7e59fbc62db26819650","impliedFormat":1},{"version":"a365c4d3bed3be4e4e20793c999c51f5cd7e6792322f14650949d827fbcd170f","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"97e5ccc7bb88419005cbdf812243a5b3186cdef81b608540acabe1be163fc3e4","affectsGlobalScope":true,"impliedFormat":1},{"version":"3fbdd025f9d4d820414417eeb4107ffa0078d454a033b506e22d3a23bc3d9c41","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"a8f8e6ab2fa07b45251f403548b78eaf2022f3c2254df3dc186cb2671fe4996d","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"9f9bb6755a8ce32d656ffa4763a8144aa4f274d6b69b59d7c32811031467216e","impliedFormat":1},{"version":"5c32bdfbd2d65e8fffbb9fbda04d7165e9181b08dad61154961852366deb7540","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"0c05e9842ec4f8b7bfebfd3ca61604bb8c914ba8da9b5337c4f25da427a005f2","impliedFormat":1},{"version":"89cd3444e389e42c56fd0d072afef31387e7f4107651afd2c03950f22dc36f77","impliedFormat":1},{"version":"7f2aa4d4989a82530aaac3f72b3dceca90e9c25bee0b1a327e8a08a1262435ad","impliedFormat":1},{"version":"e39a304f882598138a8022106cb8de332abbbb87f3fee71c5ca6b525c11c51fc","impliedFormat":1},{"version":"faed7a5153215dbd6ebe76dfdcc0af0cfe760f7362bed43284be544308b114cf","impliedFormat":1},{"version":"fcdf3e40e4a01b9a4b70931b8b51476b210c511924fcfe3f0dae19c4d52f1a54","impliedFormat":1},{"version":"345c4327b637d34a15aba4b7091eb068d6ab40a3dedaab9f00986253c9704e53","impliedFormat":1},{"version":"3a788c7fb7b1b1153d69a4d1d9e1d0dfbcf1127e703bdb02b6d12698e683d1fb","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"d38530db0601215d6d767f280e3a3c54b2a83b709e8d9001acb6f61c67e965fc","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"4805f6161c2c8cefb8d3b8bd96a080c0fe8dbc9315f6ad2e53238f9a79e528a6","impliedFormat":1},{"version":"b83cb14474fa60c5f3ec660146b97d122f0735627f80d82dd03e8caa39b4388c","impliedFormat":1},{"version":"42b81043b00ff27c6bd955aea0f6e741545f2265978bf364b614702b72a027ab","impliedFormat":1},{"version":"7274fbffbd7c9589d8d0ffba68157237afd5cecff1e99881ea3399127e60572f","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"20865ac316b8893c1a0cc383ccfc1801443fbcc2a7255be166cf90d03fac88c9","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"461d0ad8ae5f2ff981778af912ba71b37a8426a33301daa00f21c6ccb27f8156","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"fcafff163ca5e66d3b87126e756e1b6dfa8c526aa9cd2a2b0a9da837d81bbd72","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"b01bd582a6e41457bc56e6f0f9de4cb17f33f5f3843a7cf8210ac9c18472fb0f","impliedFormat":1},{"version":"8b4327413e5af38cd8cb97c59f48c3c866015d5d642f28518e3a891c469f240e","impliedFormat":1},{"version":"2b5b70d7782fe028487a80a1c214e67bd610532b9f978b78fa60f5b4a359f77e","impliedFormat":1},{"version":"7ee86fbb3754388e004de0ef9e6505485ddfb3be7640783d6d015711c03d302d","impliedFormat":1},{"version":"61dc6e3ac78d64aa864eedd0a208b97b5887cc99c5ba65c03287bf57d83b1eb9","impliedFormat":1},{"version":"43e96a3d5d1411ab40ba2f61d6a3192e58177bcf3b133a80ad2a16591611726d","impliedFormat":1},{"version":"02c4fc9e6bb27545fa021f6056e88ff5fdf10d9d9f1467f1d10536c6e749ac50","impliedFormat":1},{"version":"120599fd965257b1f4d0ff794bc696162832d9d8467224f4665f713a3119078b","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"5433f33b0a20300cca35d2f229a7fc20b0e8477c44be2affeb21cb464af60c76","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"bd4131091b773973ca5d2326c60b789ab1f5e02d8843b3587effe6e1ea7c9d86","impliedFormat":1},{"version":"c49469a5349b3cc1965710b5b0f98ed6c028686aa8450bcb3796728873eb923e","impliedFormat":1},{"version":"4a889f2c763edb4d55cb624257272ac10d04a1cad2ed2948b10ed4a7fda2a428","impliedFormat":1},{"version":"7bb79aa2fead87d9d56294ef71e056487e848d7b550c9a367523ee5416c44cfa","impliedFormat":1},{"version":"d88ea80a6447d7391f52352ec97e56b52ebec934a4a4af6e2464cfd8b39c3ba8","impliedFormat":1},{"version":"55095860901097726220b6923e35a812afdd49242a1246d7b0942ee7eb34c6e4","impliedFormat":1},{"version":"27ff4196654e6373c9af16b6165120e2dd2169f9ad6abb5c935af5abd8c7938c","impliedFormat":1},{"version":"bb8f2dbc03533abca2066ce4655c119bff353dd4514375beb93c08590c03e023","impliedFormat":1},{"version":"d193c8a86144b3a87b22bc1f5534b9c3e0f5a187873ec337c289a183973a58fe","impliedFormat":1},{"version":"1a6e6ba8a07b74e3ad237717c0299d453f9ceb795dbc2f697d1f2dd07cb782d2","impliedFormat":1},{"version":"58d70c38037fc0f949243388ff7ae20cf43321107152f14a9d36ca79311e0ada","impliedFormat":1},{"version":"c7f6485931085bf010fbaf46880a9b9ec1a285ad9dc8c695a9e936f5a48f34b4","impliedFormat":1},{"version":"796273b2edc72e78a04e86d7c58ae94d370ab93a0ddf40b1aa85a37a1c29ecd7","impliedFormat":1},{"version":"5df15a69187d737d6d8d066e189ae4f97e41f4d53712a46b2710ff9f8563ec9f","impliedFormat":1},{"version":"14f6b927888a1112d662877a5966b05ac1bf7ed25d6c84386db4c23c95a5363b","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"622694a8522b46f6310c2a9b5d2530dde1e2854cb5829354e6d1ff8f371cf469","impliedFormat":1},{"version":"ad37fb4be61c1035b68f532b7220f4e8236cf245381ce3b90ac15449ecfe7305","impliedFormat":1},{"version":"93436bd74c66baba229bfefe1314d122c01f0d4c1d9e35081a0c4f0470ac1a6c","impliedFormat":1},{"version":"d24ff95760ea2dfcc7c57d0e269356984e7046b7e0b745c80fea71559f15bdd8","impliedFormat":1},{"version":"9e2739b32f741859263fdba0244c194ca8e96da49b430377930b8f721d77c000","impliedFormat":1},{"version":"a9e6c0ff3f8186fccd05752cf75fc94e147c02645087ac6de5cc16403323d870","impliedFormat":1},{"version":"49c346823ba6d4b12278c12c977fb3a31c06b9ca719015978cb145eb86da1c61","impliedFormat":1},{"version":"bfac6e50eaa7e73bb66b7e052c38fdc8ccfc8dbde2777648642af33cf349f7f1","impliedFormat":1},{"version":"92f7c1a4da7fbfd67a2228d1687d5c2e1faa0ba865a94d3550a3941d7527a45d","impliedFormat":1},{"version":"f53b120213a9289d9a26f5af90c4c686dd71d91487a0aa5451a38366c70dc64b","impliedFormat":1},{"version":"83fe880c090afe485a5c02262c0b7cdd76a299a50c48d9bde02be8e908fb4ae6","impliedFormat":1},{"version":"13c1b657932e827a7ed510395d94fc8b743b9d053ab95b7cd829b2bc46fb06db","impliedFormat":1},{"version":"57d67b72e06059adc5e9454de26bbfe567d412b962a501d263c75c2db430f40e","impliedFormat":1},{"version":"6511e4503cf74c469c60aafd6589e4d14d5eb0a25f9bf043dcbecdf65f261972","impliedFormat":1},{"version":"078131f3a722a8ad3fc0b724cd3497176513cdcb41c80f96a3acbda2a143b58e","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"bb37588926aba35c9283fe8d46ebf4e79ffe976343105f5c6d45f282793352b2","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"72179f9dd22a86deaad4cc3490eb0fe69ee084d503b686985965654013f1391b","impliedFormat":1},{"version":"2e6114a7dd6feeef85b2c80120fdbfb59a5529c0dcc5bfa8447b6996c97a69f5","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"c8f004e6036aa1c764ad4ec543cf89a5c1893a9535c80ef3f2b653e370de45e6","impliedFormat":1},{"version":"dd80b1e600d00f5c6a6ba23f455b84a7db121219e68f89f10552c54ba46e4dc9","impliedFormat":1},{"version":"b064c36f35de7387d71c599bfcf28875849a1dbc733e82bd26cae3d1cd060521","impliedFormat":1},{"version":"05c7280d72f3ed26f346cbe7cbbbb002fb7f15739197cbbee6ab3fd1a6cb9347","impliedFormat":1},{"version":"8de9fe97fa9e00ec00666fa77ab6e91b35d25af8ca75dabcb01e14ad3299b150","impliedFormat":1},{"version":"803cd2aaf1921c218916c2c7ee3fce653e852d767177eb51047ff15b5b253893","impliedFormat":1},{"version":"7ab12b2f1249187223d11a589f5789c75177a0b597b9eb7f8e2e42d045393347","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"50256e9c31318487f3752b7ac12ff365c8949953e04568009c8705db802776fb","impliedFormat":1},{"version":"7d73b24e7bf31dfb8a931ca6c4245f6bb0814dfae17e4b60c9e194a631fe5f7b","impliedFormat":1},{"version":"d130c5f73768de51402351d5dc7d1b36eaec980ca697846e53156e4ea9911476","impliedFormat":1},{"version":"413586add0cfe7369b64979d4ec2ed56c3f771c0667fbde1bf1f10063ede0b08","impliedFormat":1},{"version":"06472528e998d152375ad3bd8ebcb69ff4694fd8d2effaf60a9d9f25a37a097a","impliedFormat":1},{"version":"50b5bc34ce6b12eccb76214b51aadfa56572aa6cc79c2b9455cdbb3d6c76af1d","impliedFormat":1},{"version":"b7e16ef7f646a50991119b205794ebfd3a4d8f8e0f314981ebbe991639023d0e","impliedFormat":1},{"version":"a401617604fa1f6ce437b81689563dfdc377069e4c58465dbd8d16069aede0a5","impliedFormat":1},{"version":"6e9082e91370de5040e415cd9f24e595b490382e8c7402c4e938a8ce4bccc99f","impliedFormat":1},{"version":"8695dec09ad439b0ceef3776ea68a232e381135b516878f0901ed2ea114fd0fe","impliedFormat":1},{"version":"304b44b1e97dd4c94697c3313df89a578dca4930a104454c99863f1784a54357","impliedFormat":1},{"version":"0a437ae178f999b46b6153d79095b60c42c996bc0458c04955f1c996dc68b971","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"4a7baeb6325920044f66c0f8e5e6f1f52e06e6d87588d837bdf44feb6f35c664","impliedFormat":1},{"version":"12d218a49dbe5655b911e6cc3c13b2c655e4c783471c3b0432137769c79e1b3c","impliedFormat":1},{"version":"6b0fc04121360f752d196ba35b6567192f422d04a97b2840d7d85f8b79921c92","impliedFormat":1},{"version":"1a82deef4c1d39f6882f28d275cad4c01f907b9b39be9cbc472fcf2cf051e05b","impliedFormat":1},{"version":"4b20fcf10a5413680e39f5666464859fc56b1003e7dfe2405ced82371ebd49b6","impliedFormat":1},{"version":"c06ef3b2569b1c1ad99fcd7fe5fba8d466e2619da5375dfa940a94e0feea899b","impliedFormat":1},{"version":"f7d628893c9fa52ba3ab01bcb5e79191636c4331ee5667ecc6373cbccff8ae12","impliedFormat":1},{"version":"1d879125d1ec570bf04bc1f362fdbe0cb538315c7ac4bcfcdf0c1e9670846aa6","impliedFormat":1},{"version":"f730b468deecf26188ad62ee8950dc29aa2aea9543bb08ed714c3db019359fd9","impliedFormat":1},{"version":"933aee906d42ea2c53b6892192a8127745f2ec81a90695df4024308ba35a8ff4","impliedFormat":1},{"version":"d663134457d8d669ae0df34eabd57028bddc04fc444c4bc04bc5215afc91e1f4","impliedFormat":1},{"version":"144bc326e90b894d1ec78a2af3ffb2eb3733f4d96761db0ca0b6239a8285f972","impliedFormat":1},{"version":"a3e3f0efcae272ab8ee3298e4e819f7d9dd9ff411101f45444877e77cfeca9a4","impliedFormat":1},{"version":"58659b06d33fa430bee1105b75cf876c0a35b2567207487c8578aec51ca2d977","impliedFormat":1},{"version":"71d9eb4c4e99456b78ae182fb20a5dfc20eb1667f091dbb9335b3c017dd1c783","impliedFormat":1},{"version":"cfa846a7b7847a1d973605fbb8c91f47f3a0f0643c18ac05c47077ebc72e71c7","impliedFormat":1},{"version":"30e6520444df1a004f46fdc8096f3fe06f7bbd93d09c53ada9dcdde59919ccca","impliedFormat":1},{"version":"6c800b281b9e89e69165fd11536195488de3ff53004e55905e6c0059a2d8591e","impliedFormat":1},{"version":"7d4254b4c6c67a29d5e7f65e67d72540480ac2cfb041ca484847f5ae70480b62","impliedFormat":1},{"version":"a58beefce74db00dbb60eb5a4bb0c6726fb94c7797c721f629142c0ae9c94306","impliedFormat":1},{"version":"41eeb453ccb75c5b2c3abef97adbbd741bd7e9112a2510e12f03f646dc9ad13d","impliedFormat":1},{"version":"502fa5863df08b806dbf33c54bee8c19f7e2ad466785c0fc35465d7c5ff80995","impliedFormat":1},{"version":"c91a2d08601a1547ffef326201be26db94356f38693bb18db622ae5e9b3d7c92","impliedFormat":1},{"version":"888cda0fa66d7f74e985a3f7b1af1f64b8ff03eb3d5e80d051c3cbdeb7f32ab7","impliedFormat":1},{"version":"60681e13f3545be5e9477acb752b741eae6eaf4cc01658a25ec05bff8b82a2ef","impliedFormat":1},{"version":"9586918b63f24124a5ca1d0cc2979821a8a57f514781f09fc5aa9cae6d7c0138","impliedFormat":1},{"version":"a57b1802794433adec9ff3fed12aa79d671faed86c49b09e02e1ac41b4f1d33a","impliedFormat":1},{"version":"ad10d4f0517599cdeca7755b930f148804e3e0e5b5a3847adce0f1f71bbccd74","impliedFormat":1},{"version":"1042064ece5bb47d6aba91648fbe0635c17c600ebdf567588b4ca715602f0a9d","impliedFormat":1},{"version":"f56bdc6884648806d34bc66d31cdb787c4718d04105ce2cd88535db214631f82","impliedFormat":1},{"version":"190da5eac6478d61ab9731ab2146fbc0164af2117a363013249b7e7992f1cccb","impliedFormat":1},{"version":"01479d9d5a5dda16d529b91811375187f61a06e74be294a35ecce77e0b9e8d6c","impliedFormat":1},{"version":"49f95e989b4632c6c2a578cc0078ee19a5831832d79cc59abecf5160ea71abad","impliedFormat":1},{"version":"9666533332f26e8995e4d6fe472bdeec9f15d405693723e6497bf94120c566c8","impliedFormat":1},{"version":"ce0df82a9ae6f914ba08409d4d883983cc08e6d59eb2df02d8e4d68309e7848b","impliedFormat":1},{"version":"1a4dc28334a926d90ba6a2d811ba0ff6c22775fcc13679521f034c124269fd40","impliedFormat":1},{"version":"f05315ff85714f0b87cc0b54bcd3dde2716e5a6b99aedcc19cad02bf2403e08c","impliedFormat":1},{"version":"8a8c64dafaba11c806efa56f5c69f611276471bef80a1db1f71316ec4168acef","impliedFormat":1},{"version":"5fad3b31fc17a5bc58095118a8b160f5260964787c52e7eb51e3d4fcf5d4a6f0","impliedFormat":1},{"version":"72105519d0390262cf0abe84cf41c926ade0ff475d35eb21307b2f94de985778","impliedFormat":1},{"version":"d0a4cac61fa080f2be5ebb68b82726be835689b35994ba0e22e3ed4d2bc45e3b","impliedFormat":1},{"version":"c857e0aae3f5f444abd791ec81206020fbcc1223e187316677e026d1c1d6fe08","impliedFormat":1},{"version":"ccf6dd45b708fb74ba9ed0f2478d4eb9195c9dfef0ff83a6092fa3cf2ff53b4f","impliedFormat":1},{"version":"2d7db1d73456e8c5075387d4240c29a2a900847f9c1bff106a2e490da8fbd457","impliedFormat":1},{"version":"2b15c805f48e4e970f8ec0b1915f22d13ca6212375e8987663e2ef5f0205e832","impliedFormat":1},{"version":"205a31b31beb7be73b8df18fcc43109cbc31f398950190a0967afc7a12cb478c","impliedFormat":1},{"version":"8fca3039857709484e5893c05c1f9126ab7451fa6c29e19bb8c2411a2e937345","impliedFormat":1},{"version":"35069c2c417bd7443ae7c7cafd1de02f665bf015479fec998985ffbbf500628c","impliedFormat":1},{"version":"dba6c7006e14a98ec82999c6f89fbbbfd1c642f41db148535f3b77b8018829b8","impliedFormat":1},{"version":"7f897b285f22a57a5c4dc14a27da2747c01084a542b4d90d33897216dceeea2e","impliedFormat":1},{"version":"7e0b7f91c5ab6e33f511efc640d36e6f933510b11be24f98836a20a2dc914c2d","impliedFormat":1},{"version":"045b752f44bf9bbdcaffd882424ab0e15cb8d11fa94e1448942e338c8ef19fba","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"d96b39301d0ded3f1a27b47759676a33a02f6f5049bfcbde81e533fd10f50dcb","impliedFormat":1},{"version":"2ded4f930d6abfaa0625cf55e58f565b7cbd4ab5b574dd2cb19f0a83a2f0be8b","impliedFormat":1},{"version":"0aedb02516baf3e66b2c1db9fef50666d6ed257edac0f866ea32f1aa05aa474f","impliedFormat":1},{"version":"ca0f4d9068d652bad47e326cf6ba424ac71ab866e44b24ddb6c2bd82d129586a","affectsGlobalScope":true,"impliedFormat":1},{"version":"04d36005fcbeac741ac50c421181f4e0316d57d148d37cc321a8ea285472462b","impliedFormat":1},{"version":"56ccb49443bfb72e5952f7012f0de1a8679f9f75fc93a5c1ac0bafb28725fc5f","impliedFormat":1},{"version":"20fa37b636fdcc1746ea0738f733d0aed17890d1cd7cb1b2f37010222c23f13e","impliedFormat":1},{"version":"d90b9f1520366d713a73bd30c5a9eb0040d0fb6076aff370796bc776fd705943","impliedFormat":1},{"version":"bc03c3c352f689e38c0ddd50c39b1e65d59273991bfc8858a9e3c0ebb79c023b","impliedFormat":1},{"version":"19df3488557c2fc9b4d8f0bac0fd20fb59aa19dec67c81f93813951a81a867f8","affectsGlobalScope":true,"impliedFormat":1},{"version":"b25350193e103ae90423c5418ddb0ad1168dc9c393c9295ef34980b990030617","affectsGlobalScope":true,"impliedFormat":1},{"version":"bef86adb77316505c6b471da1d9b8c9e428867c2566270e8894d4d773a1c4dc2","impliedFormat":1},{"version":"a46dba563f70f32f9e45ae015f3de979225f668075d7a427f874e0f6db584991","impliedFormat":1},{"version":"96171c03c2e7f314d66d38acd581f9667439845865b7f85da8df598ff9617476","impliedFormat":1},{"version":"d408d6f32de8d1aba2ff4a20f1aa6a6edd7d92c997f63b90f8ad3f9017cf5e46","impliedFormat":1},{"version":"9252d498a77517aab5d8d4b5eb9d71e4b225bbc7123df9713e08181de63180f6","impliedFormat":1},{"version":"b1f1d57fde8247599731b24a733395c880a6561ec0c882efaaf20d7df968c5af","impliedFormat":1},{"version":"9d622ea608d43eb463c0c4538fd5baa794bc18ea0bb8e96cd2ab6fd483d55fe2","impliedFormat":1},{"version":"35e6379c3f7cb27b111ad4c1aa69538fd8e788ab737b8ff7596a1b40e96f4f90","impliedFormat":1},{"version":"1fffe726740f9787f15b532e1dc870af3cd964dbe29e191e76121aa3dd8693f2","impliedFormat":1},{"version":"371bf6127c1d427836de95197155132501cb6b69ef8709176ce6e0b85d059264","impliedFormat":1},{"version":"2bafd700e617d3693d568e972d02b92224b514781f542f70d497a8fdf92d52a2","affectsGlobalScope":true,"impliedFormat":1},{"version":"5542d8a7ea13168cb573be0d1ba0d29460d59430fb12bb7bf4674efd5604e14c","impliedFormat":1},{"version":"af48e58339188d5737b608d41411a9c054685413d8ae88b8c1d0d9bfabdf6e7e","impliedFormat":1},{"version":"8c70ddc0c22d85e56011d49fddfaae3405eb53d47b59327b9dd589e82df672e7","impliedFormat":1},{"version":"a67b87d0281c97dfc1197ef28dfe397fc2c865ccd41f7e32b53f647184cc7307","impliedFormat":1},{"version":"771ffb773f1ddd562492a6b9aaca648192ac3f056f0e1d997678ff97dbb6bf9b","impliedFormat":1},{"version":"232f70c0cf2b432f3a6e56a8dc3417103eb162292a9fd376d51a3a9ea5fbbf6f","impliedFormat":1},{"version":"9e155d2255348d950b1f65643fb26c0f14f5109daf8bd9ee24a866ad0a743648","affectsGlobalScope":true,"impliedFormat":1},{"version":"0b103e9abfe82d14c0ad06a55d9f91d6747154ef7cacc73cf27ecad2bfb3afcf","impliedFormat":1},{"version":"7a883e9c84e720810f86ef4388f54938a65caa0f4d181a64e9255e847a7c9f51","impliedFormat":1},{"version":"a0ba218ac1baa3da0d5d9c1ec1a7c2f8676c284e6f5b920d6d049b13fa267377","impliedFormat":1},{"version":"bc9ee0192f056b3d5527bcd78dc3f9e527a9ba2bdc0a2c296fbc9027147df4b2","impliedFormat":1},{"version":"330896c1a2b9693edd617be24fbf9e5895d6e18c7955d6c08f028f272b37314d","impliedFormat":1},{"version":"1d9c0a9a6df4e8f29dc84c25c5aa0bb1da5456ebede7a03e03df08bb8b27bae6","impliedFormat":1},{"version":"84380af21da938a567c65ef95aefb5354f676368ee1a1cbb4cae81604a4c7d17","impliedFormat":1},{"version":"1af3e1f2a5d1332e136f8b0b95c0e6c0a02aaabd5092b36b64f3042a03debf28","impliedFormat":1},{"version":"30d8da250766efa99490fc02801047c2c6d72dd0da1bba6581c7e80d1d8842a4","impliedFormat":1},{"version":"03566202f5553bd2d9de22dfab0c61aa163cabb64f0223c08431fb3fc8f70280","impliedFormat":1},{"version":"4c0a1233155afb94bd4d7518c75c84f98567cd5f13fc215d258de196cdb40d91","impliedFormat":1},{"version":"e7765aa8bcb74a38b3230d212b4547686eb9796621ffb4367a104451c3f9614f","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"0f9c78b3b70716baaa82ed4823f6bb77469ed608a6ff2137c8935f79aa941127","impliedFormat":99},{"version":"98762b44ededffcd7be5018e761c11e89bd6a47e970595e6033cbd45e4a9d011","impliedFormat":99},{"version":"68b6a7501a56babd7bcd840e0d638ee7ec582f1e70b3c36ebf32e5e5836913c8","impliedFormat":99},{"version":"7a14bf21ae8a29d64c42173c08f026928daf418bed1b97b37ac4bb2aa197b89b","impliedFormat":99},{"version":"a56d48691b5dd2fdde127a93acfdc48294d7c0f9d400f6775cccf96e69e74841","impliedFormat":99},{"version":"ab30f03ed8946add42204d4d94f67d9a92d233567186e85de513443aa2a39ee5","impliedFormat":99},{"version":"fcbfb107aa5367daa52f716c613e12ded24e5c1d6e3c8adeecc770ae445aaa4c","impliedFormat":99},{"version":"b26f35f2b2799212182464e914ea2d2adb26b175cf194580c99aff980afa8952","impliedFormat":99},{"version":"acd8fd5090ac73902278889c38336ff3f48af6ba03aa665eb34a75e7ba1dccc4","impliedFormat":1},{"version":"d6258883868fb2680d2ca96bc8b1352cab69874581493e6d52680c5ffecdb6cc","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"f258e3960f324a956fc76a3d3d9e964fff2244ff5859dcc6ce5951e5413ca826","impliedFormat":1},{"version":"643f7232d07bf75e15bd8f658f664d6183a0efaca5eb84b48201c7671a266979","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"1de8c302fd35220d8f29dea378a4ae45199dc8ff83ca9923aca1400f2b28848a","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"98a787be42bd92f8c2a37d7df5f13e5992da0d967fab794adbb7ee18370f9849","impliedFormat":1},{"version":"332248ee37cca52903572e66c11bef755ccc6e235835e63d3c3e60ddda3e9b93","impliedFormat":1},{"version":"94e8cc88ae2ef3d920bb3bdc369f48436db123aa2dc07f683309ad8c9968a1e1","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"320f4091e33548b554d2214ce5fc31c96631b513dffa806e2e3a60766c8c49d9","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"d90d5f524de38889d1e1dbc2aeef00060d779f8688c02766ddb9ca195e4a713d","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"b0309e1eda99a9e76f87c18992d9c3689b0938266242835dd4611f2b69efe456","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"6ceb10ca57943be87ff9debe978f4ab73593c0c85ee802c051a93fc96aaf7a20","impliedFormat":1},{"version":"1de3ffe0cc28a9fe2ac761ece075826836b5a02f340b412510a59ba1d41a505a","impliedFormat":1},{"version":"e46d6cc08d243d8d0d83986f609d830991f00450fb234f5b2f861648c42dc0d8","impliedFormat":1},{"version":"1c0a98de1323051010ce5b958ad47bc1c007f7921973123c999300e2b7b0ecc0","impliedFormat":1},{"version":"b6c1f64158da02580f55e8a2728eda6805f79419aed46a930f43e68ad66a38fc","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"5bf5c7a44e779790d1eb54c234b668b15e34affa95e78eada73e5757f61ed76a","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"5c634644d45a1b6bc7b05e71e05e52ec04f3d73d9ac85d5927f647a5f965181a","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"a68d4b3182e8d776cdede7ac9630c209a7bfbb59191f99a52479151816ef9f9e","impliedFormat":99},{"version":"39644b343e4e3d748344af8182111e3bbc594930fff0170256567e13bbdbebb0","impliedFormat":99},{"version":"ed7fd5160b47b0de3b1571c5c5578e8e7e3314e33ae0b8ea85a895774ee64749","impliedFormat":99},{"version":"63a7595a5015e65262557f883463f934904959da563b4f788306f699411e9bac","impliedFormat":1},{"version":"4ba137d6553965703b6b55fd2000b4e07ba365f8caeb0359162ad7247f9707a6","impliedFormat":1},{"version":"6de125ea94866c736c6d58d68eb15272cf7d1020a5b459fea1c660027eca9a90","affectsGlobalScope":true,"impliedFormat":1},{"version":"8fac4a15690b27612d8474fb2fc7cc00388df52d169791b78d1a3645d60b4c8b","affectsGlobalScope":true,"impliedFormat":1},{"version":"064ac1c2ac4b2867c2ceaa74bbdce0cb6a4c16e7c31a6497097159c18f74aa7c","impliedFormat":1},{"version":"3dc14e1ab45e497e5d5e4295271d54ff689aeae00b4277979fdd10fa563540ae","impliedFormat":1},{"version":"d3b315763d91265d6b0e7e7fa93cfdb8a80ce7cdd2d9f55ba0f37a22db00bdb8","impliedFormat":1},{"version":"cf9a3dce951b6e4062777e867839477099a2f53adb54c66dcb001853531ac9ff","signature":"435dd9e2032d15a4e17312337de643804c9fc67018b37acfc24e0d6021af7c64"},{"version":"2d3b52720ad64fd004da688d83db83d277ce6aa9e0bc64fe954b9771849cc654","signature":"f9a0f8f4350741fd4d49b6340138a0a67c4b38081a879887154a89094a4bd800"},{"version":"a2f0f4afc5d8ec75945ebd0ff7a05af837d4ccba40b3053fb6bc890768c0fe76","impliedFormat":99},{"version":"38678c40f58bf6a1dad5eb788d11e8c1c1480930d0f236fa8e142083620bff8b","signature":"093e81cd492066822983d7a21c1b1b4188d2d369eccfc6679dfb7e3b7c990fc2"},{"version":"cf3a3a9e88e9045336e974f3984d6a4e279515544f6918fa44426e4f96e1d82e","signature":"8354cf08dfa94290b0a8f2e30538ade528b32e14508f669508c6089aef674d19"},{"version":"8e0733c50eaac49b4e84954106acc144ec1a8019922d6afcde3762523a3634af","impliedFormat":1}],"root":[317,321,356,373,389,390,827,828,830,831],"options":{"allowJs":true,"esModuleInterop":true,"jsx":4,"module":99,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[320,1],[390,2],[830,3],[356,4],[373,5],[831,6],[827,6],[828,7],[389,8],[321,9],[172,10],[164,11],[162,12],[170,13],[163,14],[171,15],[169,16],[661,14],[350,17],[349,18],[337,19],[331,20],[324,21],[351,14],[340,22],[332,19],[335,23],[333,14],[323,14],[353,24],[352,25],[334,21],[338,26],[339,21],[322,21],[341,27],[342,19],[344,28],[343,19],[345,29],[346,21],[347,30],[326,21],[328,19],[329,14],[327,19],[325,22],[330,22],[336,31],[348,21],[377,32],[358,33],[363,34],[360,35],[361,35],[362,35],[359,33],[779,32],[376,32],[780,36],[199,37],[202,38],[207,39],[208,40],[206,41],[209,14],[210,42],[213,43],[212,44],[191,45],[190,14],[173,14],[69,46],[68,14],[80,47],[67,47],[832,48],[168,14],[449,49],[450,49],[451,50],[398,51],[452,52],[453,53],[454,54],[396,14],[455,55],[456,56],[457,57],[458,58],[459,59],[460,60],[461,60],[462,61],[463,62],[464,63],[465,64],[399,14],[397,14],[466,65],[467,66],[468,67],[502,68],[469,69],[470,14],[471,70],[472,71],[473,72],[474,73],[475,74],[476,75],[477,76],[478,77],[479,78],[480,78],[481,79],[482,14],[483,80],[484,81],[486,82],[485,83],[487,84],[488,85],[489,86],[490,87],[491,88],[492,89],[493,90],[494,91],[495,92],[496,93],[497,94],[498,95],[499,96],[400,14],[401,97],[402,14],[403,14],[445,98],[446,99],[447,14],[448,84],[500,100],[501,101],[506,102],[743,33],[507,103],[505,104],[745,105],[744,106],[503,107],[741,14],[504,108],[62,14],[64,109],[740,33],[65,33],[66,14],[306,110],[307,111],[63,14],[391,112],[829,14],[308,33],[371,113],[309,14],[375,33],[310,114],[224,115],[227,116],[214,117],[220,118],[218,119],[221,119],[219,119],[217,119],[215,119],[225,119],[226,119],[223,119],[222,119],[216,120],[374,112],[354,33],[355,121],[311,122],[312,6],[315,123],[314,124],[316,123],[319,123],[313,125],[778,6],[777,6],[782,126],[366,127],[365,128],[380,129],[378,130],[781,131],[367,33],[381,6],[364,6],[384,132],[385,133],[392,134],[393,135],[382,136],[383,137],[784,138],[783,139],[394,140],[368,141],[372,142],[379,143],[129,144],[81,145],[82,145],[121,146],[112,147],[115,148],[118,149],[119,145],[120,145],[122,150],[128,151],[198,152],[197,153],[167,154],[166,155],[165,151],[318,14],[183,14],[86,156],[85,157],[84,158],[182,159],[181,160],[185,161],[184,162],[187,163],[186,164],[111,165],[110,160],[114,166],[113,160],[117,167],[116,168],[159,169],[133,170],[134,171],[135,171],[136,171],[137,171],[138,171],[139,171],[140,171],[141,171],[142,171],[143,171],[157,172],[144,171],[145,171],[146,171],[147,171],[148,171],[149,171],[150,171],[151,171],[153,171],[154,171],[152,171],[155,171],[156,171],[158,171],[132,173],[109,174],[89,175],[90,175],[91,175],[92,175],[93,175],[94,175],[95,115],[97,175],[96,175],[108,176],[98,175],[100,175],[99,175],[102,175],[101,175],[103,175],[104,175],[105,175],[106,175],[107,175],[88,175],[87,177],[176,178],[174,179],[175,179],[179,180],[177,179],[178,179],[180,179],[83,14],[357,33],[791,181],[796,182],[755,183],[541,184],[665,185],[653,186],[660,187],[558,14],[643,14],[539,14],[639,188],[681,189],[540,14],[531,190],[640,191],[641,192],[739,193],[634,194],[597,195],[647,196],[648,197],[646,198],[645,14],[642,199],[666,200],[542,201],[707,14],[708,202],[568,203],[543,204],[569,203],[600,203],[515,203],[663,205],[662,14],[652,206],[750,14],[520,14],[716,207],[717,208],[713,33],[770,14],[620,14],[719,6],[714,209],[775,210],[774,211],[769,14],[583,14],[623,212],[622,14],[768,213],[715,33],[591,214],[587,215],[592,216],[590,14],[589,217],[588,14],[771,14],[767,14],[773,218],[772,14],[586,215],[386,219],[809,220],[576,221],[575,222],[574,223],[812,33],[573,224],[563,14],[815,14],[818,14],[817,33],[819,225],[509,14],[649,226],[650,227],[651,228],[536,14],[654,14],[525,229],[508,14],[731,33],[514,230],[730,231],[729,232],[720,14],[721,14],[728,14],[723,14],[726,233],[722,14],[724,234],[727,235],[725,234],[538,14],[534,14],[535,203],[670,14],[675,236],[676,237],[674,238],[672,239],[673,240],[668,14],[737,6],[529,6],[790,241],[797,242],[801,243],[761,244],[760,14],[612,14],[820,245],[754,246],[635,247],[636,248],[711,249],[627,14],[736,250],[763,33],[628,251],[738,252],[733,253],[732,14],[734,14],[632,14],[706,254],[762,255],[765,256],[629,257],[633,258],[625,259],[618,260],[753,261],[684,262],[616,263],[516,264],[752,265],[513,266],[677,267],[669,14],[678,268],[695,269],[667,14],[694,270],[395,14],[689,271],[533,14],[709,272],[685,14],[521,14],[522,14],[693,273],[537,14],[561,274],[631,275],[759,276],[630,14],[692,14],[671,14],[697,277],[698,278],[644,14],[700,279],[702,280],[701,281],[655,14],[691,264],[704,282],[615,283],[690,284],[696,285],[546,14],[550,14],[549,14],[548,14],[553,14],[547,14],[556,14],[555,14],[552,14],[551,14],[554,14],[557,286],[545,14],[607,287],[606,14],[611,288],[608,289],[610,290],[613,288],[609,289],[526,291],[599,292],[749,293],[821,14],[805,294],[807,295],[748,296],[806,297],[766,255],[718,255],[544,14],[528,298],[527,299],[523,300],[524,301],[532,302],[560,302],[570,302],[601,303],[571,303],[518,304],[517,14],[605,305],[604,306],[603,307],[602,308],[519,309],[559,310],[747,311],[712,312],[742,313],[746,314],[638,315],[637,316],[621,317],[614,318],[596,319],[598,320],[595,321],[703,322],[617,14],[795,14],[705,323],[619,14],[562,324],[626,226],[624,325],[564,326],[679,327],[816,14],[565,328],[680,328],[793,14],[792,14],[794,14],[814,14],[682,329],[764,14],[593,330],[530,33],[577,14],[512,331],[566,14],[799,33],[511,14],[370,332],[585,33],[803,6],[584,333],[757,334],[582,332],[369,14],[387,335],[580,33],[581,33],[572,14],[510,14],[579,336],[578,337],[567,338],[710,77],[683,77],[699,14],[687,339],[686,14],[735,215],[594,33],[751,229],[758,340],[785,33],[788,341],[789,342],[786,33],[787,14],[664,97],[659,343],[658,14],[657,344],[656,14],[756,345],[798,346],[800,347],[802,348],[804,349],[808,350],[388,351],[826,352],[810,353],[776,354],[811,355],[813,356],[822,357],[825,229],[824,14],[823,358],[205,359],[204,14],[127,360],[124,361],[125,14],[126,14],[123,362],[305,363],[131,364],[130,365],[189,366],[188,367],[161,368],[160,369],[688,370],[201,371],[211,372],[192,39],[200,373],[203,374],[79,14],[196,375],[194,376],[195,377],[193,14],[76,378],[75,14],[60,14],[61,14],[10,14],[11,14],[13,14],[12,14],[2,14],[14,14],[15,14],[16,14],[17,14],[18,14],[19,14],[20,14],[21,14],[3,14],[22,14],[23,14],[4,14],[24,14],[28,14],[25,14],[26,14],[27,14],[29,14],[30,14],[31,14],[5,14],[32,14],[33,14],[34,14],[35,14],[6,14],[39,14],[36,14],[37,14],[38,14],[40,14],[7,14],[41,14],[46,14],[47,14],[42,14],[43,14],[44,14],[45,14],[8,14],[51,14],[48,14],[49,14],[50,14],[52,14],[9,14],[53,14],[54,14],[55,14],[57,14],[56,14],[1,14],[58,14],[59,14],[421,379],[433,380],[419,381],[434,382],[443,383],[410,384],[411,385],[409,386],[442,358],[437,387],[441,388],[413,389],[430,390],[412,391],[440,392],[407,393],[408,387],[414,394],[415,14],[420,395],[418,394],[405,396],[444,397],[435,398],[424,399],[423,394],[425,400],[428,401],[422,402],[426,403],[438,358],[416,404],[417,405],[429,406],[406,382],[432,407],[431,394],[427,408],[436,14],[404,14],[439,409],[78,410],[74,14],[77,411],[71,412],[70,47],[73,413],[72,414],[304,415],[298,416],[302,417],[299,417],[295,416],[303,418],[300,419],[301,417],[296,420],[297,421],[291,422],[235,423],[237,424],[290,14],[236,425],[294,426],[293,427],[292,428],[228,14],[238,423],[239,14],[230,429],[234,430],[229,14],[231,431],[232,432],[233,14],[240,433],[241,433],[242,433],[243,433],[244,433],[245,433],[246,433],[247,433],[248,433],[249,433],[250,433],[251,433],[252,433],[254,433],[253,433],[255,433],[256,433],[257,433],[258,433],[289,434],[259,433],[260,433],[261,433],[262,433],[263,433],[264,433],[265,433],[266,433],[267,433],[268,433],[269,433],[270,433],[271,433],[273,433],[272,433],[274,433],[275,433],[276,433],[277,433],[278,433],[279,433],[280,433],[281,433],[282,433],[283,433],[284,433],[285,433],[288,433],[286,433],[287,433],[317,435]],"affectedFilesPendingEmit":[320,390,830,356,373,831,827,828,389,321,317],"version":"5.9.3"} \ No newline at end of file diff --git a/drizzle/0010_add_workspace_instances.sql b/drizzle/0010_add_workspace_instances.sql new file mode 100644 index 0000000000..e8b9a22fe1 --- /dev/null +++ b/drizzle/0010_add_workspace_instances.sql @@ -0,0 +1,19 @@ +-- Create workspace_instances table for remote workspace provisioning +CREATE TABLE `workspace_instances` ( + `id` text PRIMARY KEY NOT NULL, + `task_id` text NOT NULL, + `external_id` text, + `host` text NOT NULL, + `port` integer DEFAULT 22 NOT NULL, + `username` text, + `worktree_path` text, + `status` text DEFAULT 'provisioning' NOT NULL, + `connection_id` text REFERENCES ssh_connections(id) ON DELETE SET NULL, + `created_at` integer NOT NULL, + `terminated_at` integer, + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE +); + +-- Add indexes for workspace_instances +CREATE INDEX `idx_workspace_instances_task_id` ON `workspace_instances` (`task_id`); +CREATE INDEX `idx_workspace_instances_status` ON `workspace_instances` (`status`); diff --git a/drizzle/0011_add_automations_tables.sql b/drizzle/0011_add_automations_tables.sql new file mode 100644 index 0000000000..d4c0ef5c9d --- /dev/null +++ b/drizzle/0011_add_automations_tables.sql @@ -0,0 +1,42 @@ +-- Create automations table +CREATE TABLE `automations` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `project_name` text DEFAULT '' NOT NULL, + `name` text NOT NULL, + `prompt` text NOT NULL, + `agent_id` text NOT NULL, + `schedule` text NOT NULL, + `use_worktree` integer DEFAULT 1 NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `last_run_at` text, + `next_run_at` text, + `run_count` integer DEFAULT 0 NOT NULL, + `last_run_result` text, + `last_run_error` text, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE +); + +-- Add indexes for automations +CREATE INDEX `idx_automations_project_id` ON `automations` (`project_id`); +CREATE INDEX `idx_automations_status_next_run` ON `automations` (`status`, `next_run_at`); +CREATE INDEX `idx_automations_updated_at` ON `automations` (`updated_at`); + +-- Create automation_run_logs table +CREATE TABLE `automation_run_logs` ( + `id` text PRIMARY KEY NOT NULL, + `automation_id` text NOT NULL, + `started_at` text NOT NULL, + `finished_at` text, + `status` text NOT NULL, + `error` text, + `task_id` text, + FOREIGN KEY (`automation_id`) REFERENCES `automations`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE SET NULL +); + +-- Add indexes for automation_run_logs +CREATE INDEX `idx_automation_run_logs_automation_started` ON `automation_run_logs` (`automation_id`, `started_at`); +CREATE INDEX `idx_automation_run_logs_status` ON `automation_run_logs` (`status`); diff --git a/drizzle/0012_add_automation_triggers.sql b/drizzle/0012_add_automation_triggers.sql new file mode 100644 index 0000000000..1215674a3f --- /dev/null +++ b/drizzle/0012_add_automation_triggers.sql @@ -0,0 +1,4 @@ +-- Add trigger support columns to automations table +ALTER TABLE `automations` ADD COLUMN `mode` text DEFAULT 'schedule' NOT NULL; +ALTER TABLE `automations` ADD COLUMN `trigger_type` text; +ALTER TABLE `automations` ADD COLUMN `trigger_config` text; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ec51ccb786..966386a2e7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,27 @@ "when": 1738857600000, "tag": "0009_add_ssh_support", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1741017600000, + "tag": "0010_add_workspace_instances", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1742860800000, + "tag": "0011_add_automations_tables", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1743033600000, + "tag": "0012_add_automation_triggers", + "breakpoints": true } ] } diff --git a/flake.nix b/flake.nix index 37917e95c4..5639f1513e 100644 --- a/flake.nix +++ b/flake.nix @@ -59,6 +59,19 @@ cp ${electronLinuxZip} $out/electron-v${electronVersion}-linux-x64.zip ''; + # Pre-fetch Electron headers for native module compilation (node-gyp) + electronHeaders = pkgs.fetchurl { + url = "https://www.electronjs.org/headers/v${electronVersion}/node-v${electronVersion}-headers.tar.gz"; + sha256 = "sha256-Q+c8G4nIRoJL/0uAYVYY2hrnFgvmkKB6RC3nxJtFYzU="; + }; + + # Create a node-gyp cache directory with the Electron headers + electronHeadersDir = pkgs.runCommand "electron-headers" {} '' + mkdir -p $out/${electronVersion} + tar -xzf ${electronHeaders} -C $out/${electronVersion} --strip-components=1 + echo "9" > $out/${electronVersion}/installVersion + ''; + sharedEnv = [ nodejs @@ -94,13 +107,13 @@ inherit pname version src; inherit pnpm; fetcherVersion = 1; - hash = ""; + hash = "sha256-utuVjD/5w9AihDqvwFOzTqWvQqdHcKj3PybdOE2Cef8="; } else pnpm.fetchDeps { inherit pname version src; fetcherVersion = 1; - hash = ""; + hash = "sha256-utuVjD/5w9AihDqvwFOzTqWvQqdHcKj3PybdOE2Cef8="; }; nativeBuildInputs = sharedEnv @@ -109,12 +122,38 @@ (pkgs.pnpmConfigHook or pnpm.configHook) pkgs.dpkg pkgs.rpm + pkgs.autoPatchelfHook + pkgs.makeWrapper ]; buildInputs = [ pkgs.libsecret pkgs.sqlite pkgs.zlib pkgs.libutempter + # Electron runtime dependencies + pkgs.libglvnd + pkgs.mesa + pkgs.alsa-lib + pkgs.nss + pkgs.nspr + pkgs.systemdLibs + pkgs.gtk3 + pkgs.at-spi2-atk + pkgs.at-spi2-core + pkgs.cups + pkgs.libdrm + pkgs.pango + pkgs.cairo + pkgs.xorg.libX11 + pkgs.xorg.libXcomposite + pkgs.xorg.libXdamage + pkgs.xorg.libXext + pkgs.xorg.libXfixes + pkgs.xorg.libXrandr + pkgs.xorg.libxcb + pkgs.libxkbcommon + pkgs.expat + pkgs.mesa.drivers ]; env = { HOME = "$TMPDIR/emdash-home"; @@ -133,6 +172,11 @@ # Build the app (renderer + main) pnpm run build + # Rebuild native modules (keytar, sqlite3, node-pty) for Electron + # Point node-gyp at pre-fetched headers to avoid network access + export npm_config_nodedir="${electronHeadersDir}/${electronVersion}" + pnpm exec electron-rebuild -f -v ${electronVersion} --only=sqlite3,node-pty,keytar + # Run electron-builder with electronDist override to avoid download # Use --dir to only produce unpacked output (no AppImage/deb which require network) pnpm exec electron-builder --linux --dir \ @@ -164,14 +208,16 @@ fi install -d $out/bin - cat < $out/bin/emdash -#!${pkgs.bash}/bin/bash -set -euo pipefail - -APP_ROOT="$out/share/emdash/linux-unpacked" -exec "\$APP_ROOT/emdash" "\$@" -EOF - chmod +x $out/bin/emdash + makeWrapper $out/share/emdash/linux-unpacked/emdash $out/bin/emdash \ + --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ + pkgs.libglvnd + pkgs.mesa + pkgs.alsa-lib + pkgs.nss + pkgs.nspr + pkgs.systemdLibs + pkgs.libsecret + ]}" runHook postInstall ''; diff --git a/package.json b/package.json index ad41012e91..657c2c714c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "emdash", - "version": "0.4.25", + "version": "0.4.48", "description": "A cross-platform Electron app that orchestrates multiple coding agents in parallel", + "license": "Apache-2.0", "main": "dist/main/main/entry.js", "packageManager": "pnpm@10.28.2", "scripts": { @@ -46,7 +47,11 @@ }, "devDependencies": { "@electron/rebuild": "^4.0.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20.10.0", + "@types/pidusage": "^2.0.5", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "@types/react-syntax-highlighter": "^15.5.13", @@ -63,6 +68,7 @@ "eslint-plugin-import": "^2.32.0", "eslint-plugin-react-hooks": "^7.0.0", "husky": "^9.1.7", + "jsdom": "^29.0.1", "lint-staged": "^16.3.0", "postcss": "^8.4.32", "prettier": "3.6.2", @@ -75,6 +81,7 @@ "dependencies": { "@isaacs/brace-expansion": "^5.0.1", "@monaco-editor/react": "^4.7.0", + "@posthog/react": "^1.8.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", @@ -112,13 +119,15 @@ "framer-motion": "^12.33.0", "human-id": "^4.1.2", "ignore": "^5.3.1", + "jsonc-parser": "^3.3.1", "keytar": "^7.9.0", "lucide-react": "^0.564.0", - "minimatch": "^10.1.1", + "minimatch": "^10.2.3", "monaco-editor": "^0.55.1", "motion": "^12.23.12", "nbranch": "^0.1.0", "node-pty": "1.0.0", + "pidusage": "^4.0.1", "posthog-js": "^1.297.2", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -130,6 +139,7 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "smol-toml": "^1.6.0", "sqlite3": "^5.1.7", "ssh2": "^1.17.0", "streamdown": "^1.3.0", @@ -169,6 +179,12 @@ "hardenedRuntime": true, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist", + "extendInfo": { + "LSEnvironment": { + "LANG": "en_US.UTF-8", + "LC_CTYPE": "en_US.UTF-8" + } + }, "target": [ { "target": "dmg", @@ -248,6 +264,13 @@ "npmRebuild": false }, "pnpm": { + "overrides": { + "app-builder-lib>minimatch": "10.1.2", + "minimatch@3": "3.1.4", + "minimatch@5": "5.1.8", + "minimatch@9": "9.0.7", + "minimatch@10": "10.2.3" + }, "onlyBuiltDependencies": [ "sqlite3", "node-pty", @@ -261,7 +284,10 @@ "esbuild", "protobufjs", "ssh2" - ] + ], + "patchedDependencies": { + "ssh2@1.17.0": "patches/ssh2@1.17.0.patch" + } }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/patches/ssh2@1.17.0.patch b/patches/ssh2@1.17.0.patch new file mode 100644 index 0000000000..01e8d9344e --- /dev/null +++ b/patches/ssh2@1.17.0.patch @@ -0,0 +1,111 @@ +diff --git a/lib/agent.js b/lib/agent.js +index bb495d1b191433c1be51929bb551bba1a8b03c1f..3251280ed14c53ecb925785b87e71b9ebf5f02c8 100644 +--- a/lib/agent.js ++++ b/lib/agent.js +@@ -613,12 +613,36 @@ const AgentProtocol = (() => { + return cb(new Error('Malformed agent response')); + } + ++ const rawPubKey = pubKey; + pubKey = parseKey(pubKey); + // We continue parsing the packet if we encounter an error + // in case the error is due to the key being an unsupported + // type +- if (pubKey instanceof Error) +- continue; ++ if (pubKey instanceof Error) { ++ // ssh2 fails to parse OpenSSH certificate blobs from raw ++ // buffers. Retry by converting to string format which the ++ // parser handles correctly. ++ if (Buffer.isBuffer(rawPubKey) && rawPubKey.length >= 4) { ++ const tLen = rawPubKey.readUInt32BE(0); ++ const kType = rawPubKey.slice(4, 4 + tLen).toString(); ++ if (kType.includes('cert')) { ++ const str = kType + ' ' + rawPubKey.toString('base64') ++ + ' ' + comment; ++ pubKey = parseKey(str); ++ } ++ } ++ if (pubKey instanceof Error) ++ continue; ++ } ++ ++ // For certificate keys, getPublicSSH() incorrectly returns ++ // only the inner public key. Override it to return the full ++ // certificate blob so that USERAUTH_REQUEST and ++ // SSH_AGENTC_SIGN_REQUEST use the correct data. ++ if (pubKey.type && pubKey.type.includes('cert') ++ && Buffer.isBuffer(rawPubKey)) { ++ pubKey.getPublicSSH = () => rawPubKey; ++ } + + pubKey.comment = pubKey.comment || comment; + +diff --git a/lib/protocol/Protocol.js b/lib/protocol/Protocol.js +index 7302488102161b5b627f6f089d502caa6df0f356..07abaf5917b2293ac9c3ef0fb833b794607f78cf 100644 +--- a/lib/protocol/Protocol.js ++++ b/lib/protocol/Protocol.js +@@ -701,11 +701,17 @@ class Protocol { + if (signature === false) + throw new Error('Error while converting handshake signature'); + ++ // For certificate key types, the signature algorithm in the signature ++ // blob must use the base key type (e.g. ecdsa-sha2-nistp256), not the ++ // cert type (e.g. ecdsa-sha2-nistp256-cert-v01@openssh.com). ++ const sigAlgo = keyAlgo.replace(/-cert-v\d+@openssh\.com$/, ''); ++ const sigAlgoLen = Buffer.byteLength(sigAlgo); ++ + const sigLen = signature.length; + p = this._packetRW.write.allocStart; + packet = this._packetRW.write.alloc( + 1 + 4 + userLen + 4 + 14 + 4 + 9 + 1 + 4 + algoLen + 4 + pubKeyLen + 4 +- + 4 + algoLen + 4 + sigLen ++ + 4 + sigAlgoLen + 4 + sigLen + ); + + // TODO: simply copy from original "packet" to new `packet` to avoid +@@ -729,12 +735,12 @@ class Protocol { + writeUInt32BE(packet, pubKeyLen, p += algoLen); + packet.set(pubKey, p += 4); + +- writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen); ++ writeUInt32BE(packet, 4 + sigAlgoLen + 4 + sigLen, p += pubKeyLen); + +- writeUInt32BE(packet, algoLen, p += 4); +- packet.utf8Write(keyAlgo, p += 4, algoLen); ++ writeUInt32BE(packet, sigAlgoLen, p += 4); ++ packet.utf8Write(sigAlgo, p += 4, sigAlgoLen); + +- writeUInt32BE(packet, sigLen, p += algoLen); ++ writeUInt32BE(packet, sigLen, p += sigAlgoLen); + packet.set(signature, p += 4); + + // Servers shouldn't send packet type 60 in response to signed publickey +diff --git a/lib/protocol/utils.js b/lib/protocol/utils.js +index 26f4cab6de85953ccc7f9e7af35c4fa0905e8ca0..51aa59a95be5b46689f948ce181f5093a6a3e88e 100644 +--- a/lib/protocol/utils.js ++++ b/lib/protocol/utils.js +@@ -262,7 +262,10 @@ module.exports = { + } + case 'ecdsa-sha2-nistp256': + case 'ecdsa-sha2-nistp384': +- case 'ecdsa-sha2-nistp521': { ++ case 'ecdsa-sha2-nistp521': ++ case 'ecdsa-sha2-nistp256-cert-v01@openssh.com': ++ case 'ecdsa-sha2-nistp384-cert-v01@openssh.com': ++ case 'ecdsa-sha2-nistp521-cert-v01@openssh.com': { + utilBufferParser.init(sig, 0); + const r = utilBufferParser.readString(); + const s = utilBufferParser.readString(); +@@ -319,7 +322,10 @@ module.exports = { + } + case 'ecdsa-sha2-nistp256': + case 'ecdsa-sha2-nistp384': +- case 'ecdsa-sha2-nistp521': { ++ case 'ecdsa-sha2-nistp521': ++ case 'ecdsa-sha2-nistp256-cert-v01@openssh.com': ++ case 'ecdsa-sha2-nistp384-cert-v01@openssh.com': ++ case 'ecdsa-sha2-nistp521-cert-v01@openssh.com': { + if (signature[0] === 0) + return signature; + // Convert SSH signature parameters to ASN.1 BER values for OpenSSL diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea650ed58a..0ba45657ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,18 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + app-builder-lib>minimatch: 10.1.2 + minimatch@3: 3.1.4 + minimatch@5: 5.1.8 + minimatch@9: 9.0.7 + minimatch@10: 10.2.3 + +patchedDependencies: + ssh2@1.17.0: + hash: 915ba5a860996abb3e08abc88e23ecb5c54a13c8c6cf5d05f51facb9cb1d511c + path: patches/ssh2@1.17.0.patch + importers: .: @@ -14,6 +26,9 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@posthog/react': + specifier: ^1.8.2 + version: 1.8.2(@types/react@18.3.28)(posthog-js@1.342.1)(react@18.3.1) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -125,6 +140,9 @@ importers: ignore: specifier: ^5.3.1 version: 5.3.2 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 keytar: specifier: ^7.9.0 version: 7.9.0 @@ -132,8 +150,8 @@ importers: specifier: ^0.564.0 version: 0.564.0(react@18.3.1) minimatch: - specifier: ^10.1.1 - version: 10.1.2 + specifier: 10.2.3 + version: 10.2.3 monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -146,6 +164,9 @@ importers: node-pty: specifier: 1.0.0 version: 1.0.0 + pidusage: + specifier: ^4.0.1 + version: 4.0.1 posthog-js: specifier: ^1.297.2 version: 1.342.1 @@ -179,12 +200,15 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + smol-toml: + specifier: ^1.6.0 + version: 1.6.0 sqlite3: specifier: ^5.1.7 version: 5.1.7 ssh2: specifier: ^1.17.0 - version: 1.17.0 + version: 1.17.0(patch_hash=915ba5a860996abb3e08abc88e23ecb5c54a13c8c6cf5d05f51facb9cb1d511c) streamdown: specifier: ^1.3.0 version: 1.6.11(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@18.3.1) @@ -201,9 +225,21 @@ importers: '@electron/rebuild': specifier: ^4.0.1 version: 4.0.3 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^20.10.0 version: 20.19.32 + '@types/pidusage': + specifier: ^2.0.5 + version: 2.0.5 '@types/react': specifier: ^18.2.45 version: 18.3.28 @@ -221,7 +257,7 @@ importers: version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@5.4.21(@types/node@20.19.32)) + version: 4.7.0(vite@5.4.21(@types/node@20.19.32)(lightningcss@1.31.1)) autoprefixer: specifier: ^10.4.16 version: 10.4.24(postcss@8.5.6) @@ -252,6 +288,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jsdom: + specifier: ^29.0.1 + version: 29.0.1 lint-staged: specifier: ^16.3.0 version: 16.3.0 @@ -272,16 +311,19 @@ importers: version: 5.9.3 vite: specifier: ^5.0.10 - version: 5.4.21(@types/node@20.19.32) + version: 5.4.21(@types/node@20.19.32)(lightningcss@1.31.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.32) + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.32)(jsdom@29.0.1)(lightningcss@1.31.1) packages: 7zip-bin@5.2.0: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -289,6 +331,17 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.0.4': + resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -379,6 +432,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -394,6 +451,42 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} @@ -883,6 +976,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1084,6 +1186,16 @@ packages: '@posthog/core@1.20.1': resolution: {integrity: sha512-uoTmWkYCtLYFpiK37/JCq+BuCA/OZn1qQZn5cPv1EEKt3ni3Zgg48xWCnSEyGFl5KKSXlfCruiRTwnbAtCgrBA==} + '@posthog/react@1.8.2': + resolution: {integrity: sha512-KzUuXIcAR8fAjU7IeDq+XfEcUTNvzgEGB381WRrFUUsu7jFTcKZZ6crx/ukHRCzTnoEuy5EJDkL7b7sJecPlCg==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + '@posthog/types@1.342.1': resolution: {integrity: sha512-bcyBdO88FWTkd5AVTa4Nu8T7RfY0WJrG7WMCXum/rcvNjYhS3DmOfKf8o/Bt56vA3J3yeU0vbgrmltYVoTAfaA==} @@ -1815,6 +1927,35 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tootallnate/once@1.1.2': resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -1823,6 +1964,9 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1985,6 +2129,9 @@ packages: '@types/node@20.19.32': resolution: {integrity: sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA==} + '@types/pidusage@2.0.5': + resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} + '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -2207,6 +2354,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2268,6 +2419,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -2346,6 +2504,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2360,6 +2522,9 @@ packages: resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2386,6 +2551,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2702,6 +2871,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2866,6 +3042,10 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2902,6 +3082,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2999,6 +3182,12 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} @@ -3712,6 +3901,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3939,6 +4132,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4030,6 +4226,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.0.1: + resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4056,6 +4261,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -4096,6 +4304,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -4181,6 +4463,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4198,6 +4484,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -4278,6 +4568,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -4437,23 +4730,27 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.1.2: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} + minimatch@3.1.4: + resolution: {integrity: sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==} - minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@5.1.8: + resolution: {integrity: sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==} + engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.7: + resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -4740,6 +5037,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -4791,6 +5091,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pidusage@4.0.1: + resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==} + engines: {node: '>=18'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4944,6 +5248,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.27.0: resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==} engines: {node: '>=6'} @@ -5019,6 +5327,9 @@ packages: peerDependencies: react: '*' + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -5105,6 +5416,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5179,6 +5494,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -5277,6 +5596,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -5390,6 +5713,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.6.0: + resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + engines: {node: '>= 18'} + socks-proxy-agent@6.2.1: resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} engines: {node: '>= 10'} @@ -5522,6 +5849,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -5566,6 +5897,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -5643,6 +5977,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -5654,6 +5995,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5738,6 +6087,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -5920,6 +6273,10 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -5929,6 +6286,18 @@ packages: web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5982,10 +6351,17 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -6044,6 +6420,8 @@ snapshots: 7zip-bin@5.2.0: {} + '@adobe/css-tools@4.4.4': {} + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -6051,6 +6429,24 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@7.0.4': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6167,6 +6563,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -6184,6 +6584,30 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@develar/schema-utils@2.6.5': dependencies: ajv: 6.12.6 @@ -6195,7 +6619,7 @@ snapshots: dependencies: commander: 5.1.0 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.4 '@electron/fuses@1.8.0': dependencies: @@ -6294,7 +6718,7 @@ snapshots: debug: 4.4.3 dir-compare: 3.3.0 fs-extra: 9.1.0 - minimatch: 3.1.2 + minimatch: 3.1.4 plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -6306,7 +6730,7 @@ snapshots: debug: 4.4.3 dir-compare: 4.2.0 fs-extra: 11.3.3 - minimatch: 9.0.5 + minimatch: 9.0.7 plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -6541,13 +6965,15 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.4 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -6572,7 +6998,7 @@ snapshots: dependencies: '@humanwhocodes/object-schema': 2.0.3 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.4 transitivePeerDependencies: - supports-color @@ -6779,6 +7205,13 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@posthog/react@1.8.2(@types/react@18.3.28)(posthog-js@1.342.1)(react@18.3.1)': + dependencies: + posthog-js: 1.342.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + '@posthog/types@1.342.1': {} '@protobufjs/aspromise@1.1.2': {} @@ -7462,11 +7895,47 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 18.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tootallnate/once@1.1.2': optional: true '@tootallnate/once@2.0.0': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -7669,6 +8138,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pidusage@2.0.5': {} + '@types/plist@3.0.5': dependencies: '@types/node': 20.19.32 @@ -7784,7 +8255,7 @@ snapshots: debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - minimatch: 9.0.3 + minimatch: 9.0.7 semver: 7.7.4 ts-api-utils: 1.4.3(typescript@5.9.3) optionalDependencies: @@ -7813,7 +8284,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.32))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.32)(lightningcss@1.31.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -7821,7 +8292,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@20.19.32) + vite: 5.4.21(@types/node@20.19.32)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color @@ -7833,13 +8304,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@20.19.32))': + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@20.19.32)(lightningcss@1.31.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@20.19.32) + vite: 5.4.21(@types/node@20.19.32)(lightningcss@1.31.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -7932,6 +8403,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -7970,7 +8443,7 @@ snapshots: isbinaryfile: 5.0.7 js-yaml: 4.1.1 lazy-val: 1.0.5 - minimatch: 5.1.6 + minimatch: 10.1.2 read-config-file: 6.3.2 sanitize-filename: 1.6.3 semver: 7.7.4 @@ -8075,6 +8548,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -8166,6 +8645,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.19: {} @@ -8179,6 +8660,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -8209,6 +8694,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -8601,6 +9090,13 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -8789,6 +9285,13 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -8821,6 +9324,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -8882,11 +9387,11 @@ snapshots: dir-compare@3.3.0: dependencies: buffer-equal: 1.0.1 - minimatch: 3.1.2 + minimatch: 3.1.4 dir-compare@4.2.0: dependencies: - minimatch: 3.1.2 + minimatch: 3.1.4 p-limit: 3.1.0 dir-glob@3.0.1: @@ -8928,6 +9433,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -9286,7 +9795,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.4 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -9353,7 +9862,7 @@ snapshots: json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.4 natural-compare: 1.4.0 optionator: 0.9.4 strip-ansi: 6.0.1 @@ -9460,7 +9969,7 @@ snapshots: filelist@1.0.4: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.8 fill-range@7.1.1: dependencies: @@ -9638,7 +10147,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 + minimatch: 9.0.7 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -9648,7 +10157,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.4 once: 1.4.0 path-is-absolute: 1.0.1 @@ -9881,6 +10390,12 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -9962,8 +10477,7 @@ snapshots: imurmurhash@0.1.4: {} - indent-string@4.0.0: - optional: true + indent-string@4.0.0: {} infer-owner@1.0.4: optional: true @@ -10105,6 +10619,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10184,6 +10700,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.0.1: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@asamuzakjp/dom-selector': 7.0.4 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) + '@exodus/bytes': 1.15.0 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.6 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -10201,6 +10743,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -10249,6 +10793,56 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + optional: true + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -10330,6 +10924,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -10346,6 +10942,8 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10567,6 +11165,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -10848,25 +11448,27 @@ snapshots: mimic-response@3.1.0: {} + min-indent@1.0.1: {} + minimatch@10.1.2: dependencies: '@isaacs/brace-expansion': 5.0.1 - minimatch@3.1.2: + minimatch@10.2.3: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 5.0.4 - minimatch@5.1.6: + minimatch@3.1.4: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 1.1.12 - minimatch@9.0.3: + minimatch@5.1.8: dependencies: brace-expansion: 2.0.2 - minimatch@9.0.5: + minimatch@9.0.7: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.4 minimist@1.2.8: {} @@ -11198,6 +11800,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -11229,6 +11835,10 @@ snapshots: picomatch@4.0.3: {} + pidusage@4.0.1: + dependencies: + safe-buffer: 5.2.1 + pify@2.3.0: {} pirates@4.0.7: {} @@ -11333,6 +11943,12 @@ snapshots: prettier@3.6.2: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.27.0: {} prismjs@1.30.0: {} @@ -11408,6 +12024,8 @@ snapshots: dependencies: react: 18.3.1 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.28)(react@18.3.1): dependencies: '@types/hast': 3.0.4 @@ -11516,12 +12134,17 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.8 readdirp@3.6.0: dependencies: picomatch: 2.3.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -11650,6 +12273,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -11781,6 +12406,10 @@ snapshots: sax@1.4.4: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -11915,6 +12544,8 @@ snapshots: smart-buffer@4.2.0: {} + smol-toml@1.6.0: {} + socks-proxy-agent@6.2.1: dependencies: agent-base: 6.0.2 @@ -11967,7 +12598,7 @@ snapshots: - bluebird - supports-color - ssh2@1.17.0: + ssh2@1.17.0(patch_hash=915ba5a860996abb3e08abc88e23ecb5c54a13c8c6cf5d05f51facb9cb1d511c): dependencies: asn1: 0.2.6 bcrypt-pbkdf: 1.0.2 @@ -12104,6 +12735,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -12150,6 +12785,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwind-merge@2.6.1: {} tailwind-merge@3.4.0: {} @@ -12256,6 +12893,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -12266,6 +12909,14 @@ snapshots: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -12356,6 +13007,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.24.6: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -12474,13 +13127,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@20.19.32): + vite-node@3.2.4(@types/node@20.19.32)(lightningcss@1.31.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.21(@types/node@20.19.32) + vite: 5.4.21(@types/node@20.19.32)(lightningcss@1.31.1) transitivePeerDependencies: - '@types/node' - less @@ -12492,7 +13145,7 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@20.19.32): + vite@5.4.21(@types/node@20.19.32)(lightningcss@1.31.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -12500,12 +13153,13 @@ snapshots: optionalDependencies: '@types/node': 20.19.32 fsevents: 2.3.3 + lightningcss: 1.31.1 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.32): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.32)(jsdom@29.0.1)(lightningcss@1.31.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@20.19.32)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@20.19.32)(lightningcss@1.31.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12523,12 +13177,13 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 5.4.21(@types/node@20.19.32) - vite-node: 3.2.4(@types/node@20.19.32) + vite: 5.4.21(@types/node@20.19.32)(lightningcss@1.31.1) + vite-node: 3.2.4(@types/node@20.19.32)(lightningcss@1.31.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 '@types/node': 20.19.32 + jsdom: 29.0.1 transitivePeerDependencies: - less - lightningcss @@ -12557,6 +13212,10 @@ snapshots: vscode-uri@3.0.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -12565,6 +13224,18 @@ snapshots: web-vitals@5.1.0: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -12646,8 +13317,12 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/assets/images/Forgejo.svg b/src/assets/images/Forgejo.svg new file mode 100644 index 0000000000..e409658d99 --- /dev/null +++ b/src/assets/images/Forgejo.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/Plain.svg b/src/assets/images/Plain.svg new file mode 100644 index 0000000000..9ab1aa7864 --- /dev/null +++ b/src/assets/images/Plain.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/Sentry.svg b/src/assets/images/Sentry.svg new file mode 100644 index 0000000000..11bb3c8acb --- /dev/null +++ b/src/assets/images/Sentry.svg @@ -0,0 +1 @@ +Sentry \ No newline at end of file diff --git a/src/assets/images/android-studio.svg b/src/assets/images/android-studio.svg new file mode 100644 index 0000000000..77b9e55afa --- /dev/null +++ b/src/assets/images/android-studio.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/aws.png b/src/assets/images/aws.png new file mode 100644 index 0000000000..bfaf690520 Binary files /dev/null and b/src/assets/images/aws.png differ diff --git a/src/assets/images/azure.svg b/src/assets/images/azure.svg new file mode 100644 index 0000000000..ff5dfa5c11 --- /dev/null +++ b/src/assets/images/azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/docker.svg b/src/assets/images/docker.svg new file mode 100644 index 0000000000..09a5a664af --- /dev/null +++ b/src/assets/images/docker.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/forge.svg b/src/assets/images/forge.svg new file mode 100644 index 0000000000..2d97fe413c --- /dev/null +++ b/src/assets/images/forge.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/hermesagent.jpg b/src/assets/images/hermesagent.jpg new file mode 100644 index 0000000000..5edec869f1 Binary files /dev/null and b/src/assets/images/hermesagent.jpg differ diff --git a/src/assets/images/hetzner.png b/src/assets/images/hetzner.png new file mode 100644 index 0000000000..707e4097c3 Binary files /dev/null and b/src/assets/images/hetzner.png differ diff --git a/src/assets/images/mcp/amplitude.png b/src/assets/images/mcp/amplitude.png new file mode 100644 index 0000000000..72a5a20851 Binary files /dev/null and b/src/assets/images/mcp/amplitude.png differ diff --git a/src/assets/images/mcp/asana.svg b/src/assets/images/mcp/asana.svg new file mode 100644 index 0000000000..eebccc8cac --- /dev/null +++ b/src/assets/images/mcp/asana.svg @@ -0,0 +1 @@ +Asana \ No newline at end of file diff --git a/src/assets/images/mcp/atlassian.svg b/src/assets/images/mcp/atlassian.svg new file mode 100644 index 0000000000..8752138604 --- /dev/null +++ b/src/assets/images/mcp/atlassian.svg @@ -0,0 +1 @@ +Atlassian \ No newline at end of file diff --git a/src/assets/images/mcp/aws_marketplace.svg b/src/assets/images/mcp/aws_marketplace.svg new file mode 100644 index 0000000000..9fd688c41f --- /dev/null +++ b/src/assets/images/mcp/aws_marketplace.svg @@ -0,0 +1 @@ +Amazon AWS \ No newline at end of file diff --git a/src/assets/images/mcp/bigquery.svg b/src/assets/images/mcp/bigquery.svg new file mode 100644 index 0000000000..5aa562ba11 --- /dev/null +++ b/src/assets/images/mcp/bigquery.svg @@ -0,0 +1 @@ +Google BigQuery \ No newline at end of file diff --git a/src/assets/images/mcp/canva.svg b/src/assets/images/mcp/canva.svg new file mode 100644 index 0000000000..f3d793d093 --- /dev/null +++ b/src/assets/images/mcp/canva.svg @@ -0,0 +1 @@ +Canva \ No newline at end of file diff --git a/src/assets/images/mcp/chrome_devtools.svg b/src/assets/images/mcp/chrome_devtools.svg new file mode 100644 index 0000000000..919ac2a1a8 --- /dev/null +++ b/src/assets/images/mcp/chrome_devtools.svg @@ -0,0 +1 @@ +Google Chrome \ No newline at end of file diff --git a/src/assets/images/mcp/clerk.svg b/src/assets/images/mcp/clerk.svg new file mode 100644 index 0000000000..b30e97ef65 --- /dev/null +++ b/src/assets/images/mcp/clerk.svg @@ -0,0 +1 @@ +Clerk \ No newline at end of file diff --git a/src/assets/images/mcp/clickup.svg b/src/assets/images/mcp/clickup.svg new file mode 100644 index 0000000000..4bf99cfd82 --- /dev/null +++ b/src/assets/images/mcp/clickup.svg @@ -0,0 +1 @@ +ClickUp \ No newline at end of file diff --git a/src/assets/images/mcp/cloudflare.svg b/src/assets/images/mcp/cloudflare.svg new file mode 100644 index 0000000000..a1cf2d6d3d --- /dev/null +++ b/src/assets/images/mcp/cloudflare.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/src/assets/images/mcp/cloudinary.svg b/src/assets/images/mcp/cloudinary.svg new file mode 100644 index 0000000000..28ab3c967f --- /dev/null +++ b/src/assets/images/mcp/cloudinary.svg @@ -0,0 +1 @@ +Cloudinary \ No newline at end of file diff --git a/src/assets/images/mcp/deepwiki.png b/src/assets/images/mcp/deepwiki.png new file mode 100644 index 0000000000..64e0459c45 Binary files /dev/null and b/src/assets/images/mcp/deepwiki.png differ diff --git a/src/assets/images/mcp/devrev.svg b/src/assets/images/mcp/devrev.svg new file mode 100644 index 0000000000..d49a55106e --- /dev/null +++ b/src/assets/images/mcp/devrev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/mcp/exa.png b/src/assets/images/mcp/exa.png new file mode 100644 index 0000000000..53cf24845c Binary files /dev/null and b/src/assets/images/mcp/exa.png differ diff --git a/src/assets/images/mcp/figma.svg b/src/assets/images/mcp/figma.svg new file mode 100644 index 0000000000..6a06daa63a --- /dev/null +++ b/src/assets/images/mcp/figma.svg @@ -0,0 +1 @@ +Figma \ No newline at end of file diff --git a/src/assets/images/mcp/graphos.svg b/src/assets/images/mcp/graphos.svg new file mode 100644 index 0000000000..fb6c1f346d --- /dev/null +++ b/src/assets/images/mcp/graphos.svg @@ -0,0 +1 @@ +Apollo GraphQL \ No newline at end of file diff --git a/src/assets/images/mcp/honeycomb.png b/src/assets/images/mcp/honeycomb.png new file mode 100644 index 0000000000..ac803e66c6 Binary files /dev/null and b/src/assets/images/mcp/honeycomb.png differ diff --git a/src/assets/images/mcp/hugging_face.svg b/src/assets/images/mcp/hugging_face.svg new file mode 100644 index 0000000000..dd2db93b2d --- /dev/null +++ b/src/assets/images/mcp/hugging_face.svg @@ -0,0 +1 @@ +Hugging Face \ No newline at end of file diff --git a/src/assets/images/mcp/intercom.svg b/src/assets/images/mcp/intercom.svg new file mode 100644 index 0000000000..3a0ec690cf --- /dev/null +++ b/src/assets/images/mcp/intercom.svg @@ -0,0 +1 @@ +Intercom \ No newline at end of file diff --git a/src/assets/images/mcp/jam.png b/src/assets/images/mcp/jam.png new file mode 100644 index 0000000000..a1ac546b4c Binary files /dev/null and b/src/assets/images/mcp/jam.png differ diff --git a/src/assets/images/mcp/linear.svg b/src/assets/images/mcp/linear.svg new file mode 100644 index 0000000000..f3770d654e --- /dev/null +++ b/src/assets/images/mcp/linear.svg @@ -0,0 +1 @@ +Linear \ No newline at end of file diff --git a/src/assets/images/mcp/magic_patterns.png b/src/assets/images/mcp/magic_patterns.png new file mode 100644 index 0000000000..f264b7eb8a Binary files /dev/null and b/src/assets/images/mcp/magic_patterns.png differ diff --git a/src/assets/images/mcp/make.svg b/src/assets/images/mcp/make.svg new file mode 100644 index 0000000000..1365fe619b --- /dev/null +++ b/src/assets/images/mcp/make.svg @@ -0,0 +1 @@ +Make \ No newline at end of file diff --git a/src/assets/images/mcp/microsoft_learn.svg b/src/assets/images/mcp/microsoft_learn.svg new file mode 100644 index 0000000000..eeacf25206 --- /dev/null +++ b/src/assets/images/mcp/microsoft_learn.svg @@ -0,0 +1 @@ +Microsoft \ No newline at end of file diff --git a/src/assets/images/mcp/miro.svg b/src/assets/images/mcp/miro.svg new file mode 100644 index 0000000000..f8a11806da --- /dev/null +++ b/src/assets/images/mcp/miro.svg @@ -0,0 +1 @@ +Miro \ No newline at end of file diff --git a/src/assets/images/mcp/motherduck.png b/src/assets/images/mcp/motherduck.png new file mode 100644 index 0000000000..653e3cdf7d Binary files /dev/null and b/src/assets/images/mcp/motherduck.png differ diff --git a/src/assets/images/mcp/netlify.svg b/src/assets/images/mcp/netlify.svg new file mode 100644 index 0000000000..085838e3a8 --- /dev/null +++ b/src/assets/images/mcp/netlify.svg @@ -0,0 +1 @@ +Netlify \ No newline at end of file diff --git a/src/assets/images/mcp/notion.svg b/src/assets/images/mcp/notion.svg new file mode 100644 index 0000000000..2917f42eeb --- /dev/null +++ b/src/assets/images/mcp/notion.svg @@ -0,0 +1 @@ +Notion \ No newline at end of file diff --git a/src/assets/images/mcp/planetscale.svg b/src/assets/images/mcp/planetscale.svg new file mode 100644 index 0000000000..284eeb740b --- /dev/null +++ b/src/assets/images/mcp/planetscale.svg @@ -0,0 +1 @@ +PlanetScale \ No newline at end of file diff --git a/src/assets/images/mcp/playwright.svg b/src/assets/images/mcp/playwright.svg new file mode 100644 index 0000000000..3d45a76393 --- /dev/null +++ b/src/assets/images/mcp/playwright.svg @@ -0,0 +1 @@ +Playwright \ No newline at end of file diff --git a/src/assets/images/mcp/posthog.svg b/src/assets/images/mcp/posthog.svg new file mode 100644 index 0000000000..70d8cb7782 --- /dev/null +++ b/src/assets/images/mcp/posthog.svg @@ -0,0 +1 @@ +PostHog \ No newline at end of file diff --git a/src/assets/images/mcp/sanity.svg b/src/assets/images/mcp/sanity.svg new file mode 100644 index 0000000000..6a7ad4b777 --- /dev/null +++ b/src/assets/images/mcp/sanity.svg @@ -0,0 +1 @@ +Sanity \ No newline at end of file diff --git a/src/assets/images/mcp/sentry.svg b/src/assets/images/mcp/sentry.svg new file mode 100644 index 0000000000..11bb3c8acb --- /dev/null +++ b/src/assets/images/mcp/sentry.svg @@ -0,0 +1 @@ +Sentry \ No newline at end of file diff --git a/src/assets/images/mcp/slack.svg b/src/assets/images/mcp/slack.svg new file mode 100644 index 0000000000..004e266300 --- /dev/null +++ b/src/assets/images/mcp/slack.svg @@ -0,0 +1 @@ +Slack \ No newline at end of file diff --git a/src/assets/images/mcp/stripe.svg b/src/assets/images/mcp/stripe.svg new file mode 100644 index 0000000000..8ebadf74d3 --- /dev/null +++ b/src/assets/images/mcp/stripe.svg @@ -0,0 +1 @@ +Stripe \ No newline at end of file diff --git a/src/assets/images/mcp/supabase.svg b/src/assets/images/mcp/supabase.svg new file mode 100644 index 0000000000..b773557067 --- /dev/null +++ b/src/assets/images/mcp/supabase.svg @@ -0,0 +1 @@ +Supabase \ No newline at end of file diff --git a/src/assets/images/mcp/vercel.svg b/src/assets/images/mcp/vercel.svg new file mode 100644 index 0000000000..821ecfff21 --- /dev/null +++ b/src/assets/images/mcp/vercel.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/src/assets/images/mcp/webflow.svg b/src/assets/images/mcp/webflow.svg new file mode 100644 index 0000000000..c5c6fca68e --- /dev/null +++ b/src/assets/images/mcp/webflow.svg @@ -0,0 +1 @@ +Webflow \ No newline at end of file diff --git a/src/assets/images/mcp/wix.svg b/src/assets/images/mcp/wix.svg new file mode 100644 index 0000000000..0408309d44 --- /dev/null +++ b/src/assets/images/mcp/wix.svg @@ -0,0 +1 @@ +Wix \ No newline at end of file diff --git a/src/assets/images/mcp/wordpress.svg b/src/assets/images/mcp/wordpress.svg new file mode 100644 index 0000000000..08ecac100f --- /dev/null +++ b/src/assets/images/mcp/wordpress.svg @@ -0,0 +1 @@ +WordPress \ No newline at end of file diff --git a/src/assets/images/phpstorm.svg b/src/assets/images/phpstorm.svg new file mode 100644 index 0000000000..98bc48017e --- /dev/null +++ b/src/assets/images/phpstorm.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/skills/anthropic.svg b/src/assets/images/skills/anthropic.svg new file mode 100644 index 0000000000..c917480d32 --- /dev/null +++ b/src/assets/images/skills/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/src/assets/images/skills/apple.svg b/src/assets/images/skills/apple.svg new file mode 100644 index 0000000000..de4b0d17b3 --- /dev/null +++ b/src/assets/images/skills/apple.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/src/assets/images/skills/bun.svg b/src/assets/images/skills/bun.svg new file mode 100644 index 0000000000..c9090d5fbe --- /dev/null +++ b/src/assets/images/skills/bun.svg @@ -0,0 +1 @@ +Bun \ No newline at end of file diff --git a/src/assets/images/skills/github.svg b/src/assets/images/skills/github.svg new file mode 100644 index 0000000000..538ec5bf2a --- /dev/null +++ b/src/assets/images/skills/github.svg @@ -0,0 +1 @@ +GitHub \ No newline at end of file diff --git a/src/assets/images/skills/jupyter.svg b/src/assets/images/skills/jupyter.svg new file mode 100644 index 0000000000..9353854efa --- /dev/null +++ b/src/assets/images/skills/jupyter.svg @@ -0,0 +1 @@ +Jupyter \ No newline at end of file diff --git a/src/assets/images/skills/mysql.svg b/src/assets/images/skills/mysql.svg new file mode 100644 index 0000000000..195caada06 --- /dev/null +++ b/src/assets/images/skills/mysql.svg @@ -0,0 +1 @@ +MySQL \ No newline at end of file diff --git a/src/assets/images/skills/openai.svg b/src/assets/images/skills/openai.svg new file mode 100644 index 0000000000..7d9d324ed0 --- /dev/null +++ b/src/assets/images/skills/openai.svg @@ -0,0 +1 @@ +OpenAI diff --git a/src/assets/images/skills/postgresql.svg b/src/assets/images/skills/postgresql.svg new file mode 100644 index 0000000000..dcf75b726d --- /dev/null +++ b/src/assets/images/skills/postgresql.svg @@ -0,0 +1 @@ +PostgreSQL \ No newline at end of file diff --git a/src/assets/images/skills/react.svg b/src/assets/images/skills/react.svg new file mode 100644 index 0000000000..6006995638 --- /dev/null +++ b/src/assets/images/skills/react.svg @@ -0,0 +1 @@ +React \ No newline at end of file diff --git a/src/assets/images/skills/render.svg b/src/assets/images/skills/render.svg new file mode 100644 index 0000000000..34d7d34860 --- /dev/null +++ b/src/assets/images/skills/render.svg @@ -0,0 +1 @@ +Render \ No newline at end of file diff --git a/src/assets/images/skills/resend.svg b/src/assets/images/skills/resend.svg new file mode 100644 index 0000000000..1a625a9d15 --- /dev/null +++ b/src/assets/images/skills/resend.svg @@ -0,0 +1 @@ +Resend \ No newline at end of file diff --git a/src/assets/images/skills/shadcn.svg b/src/assets/images/skills/shadcn.svg new file mode 100644 index 0000000000..5a23e483ed --- /dev/null +++ b/src/assets/images/skills/shadcn.svg @@ -0,0 +1 @@ +shadcn/ui \ No newline at end of file diff --git a/src/assets/images/skills/swift.svg b/src/assets/images/skills/swift.svg new file mode 100644 index 0000000000..ffaf098efc --- /dev/null +++ b/src/assets/images/skills/swift.svg @@ -0,0 +1 @@ +Swift \ No newline at end of file diff --git a/src/assets/images/skills/xcode.svg b/src/assets/images/skills/xcode.svg new file mode 100644 index 0000000000..c10eb77cdd --- /dev/null +++ b/src/assets/images/skills/xcode.svg @@ -0,0 +1 @@ +Xcode \ No newline at end of file diff --git a/src/assets/images/vscodium.png b/src/assets/images/vscodium.png new file mode 100644 index 0000000000..fa0376365e Binary files /dev/null and b/src/assets/images/vscodium.png differ diff --git a/src/assets/images/windsurf.svg b/src/assets/images/windsurf.svg new file mode 100644 index 0000000000..ffa57d0c35 --- /dev/null +++ b/src/assets/images/windsurf.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/images/xcode.png b/src/assets/images/xcode.png new file mode 100644 index 0000000000..3eafc3d7ab Binary files /dev/null and b/src/assets/images/xcode.png differ diff --git a/src/main/app/menu.ts b/src/main/app/menu.ts index 573b7b056b..ba5453fb09 100644 --- a/src/main/app/menu.ts +++ b/src/main/app/menu.ts @@ -1,4 +1,4 @@ -import { Menu, shell, app, BrowserWindow, nativeImage } from 'electron'; +import { Menu, shell, app, BrowserWindow, ipcMain } from 'electron'; import { EMDASH_RELEASES_URL, EMDASH_DOCS_URL } from '@shared/urls'; function getFocusedWindow(): BrowserWindow | null { @@ -10,6 +10,77 @@ function sendToRenderer(channel: string) { if (win) win.webContents.send(channel); } +/** Menu labels exposed to the renderer for the custom title-bar menu. */ +export type TitlebarMenuLabel = 'File' | 'Edit' | 'View' | 'Window' | 'Help'; + +function getWindowsMenuTemplate(): Record< + TitlebarMenuLabel, + Electron.MenuItemConstructorOptions[] +> { + return { + File: [ + { + label: 'Settings\u2026', + accelerator: 'CmdOrCtrl+,', + click: () => sendToRenderer('menu:open-settings'), + }, + { type: 'separator' }, + { + label: 'Close Tab', + accelerator: 'CmdOrCtrl+W', + click: () => sendToRenderer('menu:close-tab'), + }, + { type: 'separator' }, + { role: 'quit' }, + ], + Edit: [ + { + label: 'Undo', + accelerator: 'CmdOrCtrl+Z', + click: () => sendToRenderer('menu:undo'), + }, + { + label: 'Redo', + accelerator: 'CmdOrCtrl+Y', + click: () => sendToRenderer('menu:redo'), + }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'delete' }, + { role: 'selectAll' }, + ], + View: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + Window: [{ role: 'minimize' }, { role: 'zoom' }, { role: 'close' }], + Help: [ + { + label: 'Docs', + click: () => shell.openExternal(EMDASH_DOCS_URL), + }, + { + label: 'Changelog', + click: () => shell.openExternal(EMDASH_RELEASES_URL), + }, + { type: 'separator' }, + { + label: 'Check for Updates\u2026', + click: () => sendToRenderer('menu:check-for-updates'), + }, + ], + }; +} + export function setupApplicationMenu(): void { const isMac = process.platform === 'darwin'; @@ -65,13 +136,12 @@ export function setupApplicationMenu(): void { { type: 'separator' as const }, ] : []), - isMac - ? { - label: 'Close Tab', - accelerator: 'CmdOrCtrl+W', - click: () => sendToRenderer('menu:close-tab'), - } - : { role: 'quit' as const }, + { + label: 'Close Tab', + accelerator: 'CmdOrCtrl+W', + click: () => sendToRenderer('menu:close-tab'), + }, + ...(!isMac ? [{ type: 'separator' as const }, { role: 'quit' as const }] : []), ], }, // Edit menu @@ -141,4 +211,18 @@ export function setupApplicationMenu(): void { const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); + + // Register IPC handler for popup menus (custom title bar on Windows/Linux) + if (!isMac) { + const windowsMenus = getWindowsMenuTemplate(); + + ipcMain.handle('app:popupMenu', (_event, args: { label: string; x: number; y: number }) => { + const win = getFocusedWindow(); + if (!win) return; + const items = windowsMenus[args.label as TitlebarMenuLabel]; + if (!items) return; + const contextMenu = Menu.buildFromTemplate(items); + contextMenu.popup({ window: win, x: Math.round(args.x), y: Math.round(args.y) }); + }); + } } diff --git a/src/main/app/window.ts b/src/main/app/window.ts index ff7da73af6..042eb5c7f2 100644 --- a/src/main/app/window.ts +++ b/src/main/app/window.ts @@ -30,12 +30,20 @@ export function createMainWindow(): BrowserWindow { // Preload is emitted to dist/main/main/preload.js preload: join(__dirname, '..', 'preload.js'), }, - ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset' } : {}), + ...(process.platform === 'darwin' + ? { + titleBarStyle: 'hiddenInset' as const, + trafficLightPosition: { x: 16, y: 12 }, + // Enable Window Controls Overlay API so the renderer can use + // env(titlebar-area-x) to position content after the traffic lights. + titleBarOverlay: { height: 36 }, + } + : { frame: false }), show: false, }); if (isDev) { - mainWindow.loadURL('http://localhost:3000'); + mainWindow.loadURL(`http://localhost:${process.env.EMDASH_DEV_PORT || 3000}`); } else { // Serve renderer over an HTTP origin in production so embeds work. const rendererRoot = join(app.getAppPath(), 'dist', 'renderer'); @@ -71,6 +79,16 @@ export function createMainWindow(): BrowserWindow { }); }); + // Notify renderer of maximize/unmaximize for custom title bar + if (process.platform !== 'darwin') { + mainWindow.on('maximize', () => { + mainWindow?.webContents.send('window:maximized'); + }); + mainWindow.on('unmaximize', () => { + mainWindow?.webContents.send('window:unmaximized'); + }); + } + // Cleanup reference on close mainWindow.on('closed', () => { mainWindow = null; diff --git a/src/main/config/github.config.ts b/src/main/config/github.config.ts index 102d94b08f..898ba1892d 100644 --- a/src/main/config/github.config.ts +++ b/src/main/config/github.config.ts @@ -1,8 +1,9 @@ -/** - * GitHub OAuth configuration for Device Flow authentication. - * No client secret needed - Device Flow is designed for desktop/CLI apps. - */ export const GITHUB_CONFIG = { clientId: 'Ov23ligC35uHWopzCeWf', scopes: ['repo', 'read:user', 'read:org'], + + oauthServer: { + baseUrl: process.env.EMDASH_AUTH_SERVER_URL || 'https://auth.emdash.sh', + authTimeoutMs: Number(process.env.EMDASH_AUTH_TIMEOUT_MS || 300000), + }, }; diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 44082d0843..f20adcd24c 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -128,6 +128,7 @@ export const messages = sqliteTable( }) ); +// TODO: remove after refactor (resolves migration issues) export const lineComments = sqliteTable( 'line_comments', { @@ -152,8 +153,95 @@ export const lineComments = sqliteTable( }) ); +export const workspaceInstances = sqliteTable( + 'workspace_instances', + { + id: text('id').primaryKey(), + taskId: text('task_id') + .notNull() + .references(() => tasks.id, { onDelete: 'cascade' }), + externalId: text('external_id'), // "id" from script output (e.g. workspace name); nullable + host: text('host').notNull(), + port: integer('port').notNull().default(22), + username: text('username'), + worktreePath: text('worktree_path'), + status: text('status').notNull().default('provisioning'), // provisioning | ready | terminated | error + connectionId: text('connection_id').references(() => sshConnections.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at').notNull(), + terminatedAt: integer('terminated_at'), + }, + (table) => ({ + taskIdIdx: index('idx_workspace_instances_task_id').on(table.taskId), + statusIdx: index('idx_workspace_instances_status').on(table.status), + }) +); + +export const automations = sqliteTable( + 'automations', + { + id: text('id').primaryKey(), + projectId: text('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + projectName: text('project_name').notNull().default(''), + name: text('name').notNull(), + prompt: text('prompt').notNull(), + agentId: text('agent_id').notNull(), + mode: text('mode').notNull().default('schedule'), // 'schedule' | 'trigger' + schedule: text('schedule').notNull(), // JSON encoded AutomationSchedule + triggerType: text('trigger_type'), // 'github_pr' | 'github_issue' | 'linear_issue' + triggerConfig: text('trigger_config'), // JSON encoded TriggerConfig + useWorktree: integer('use_worktree').notNull().default(1), // boolean + status: text('status').notNull().default('active'), + lastRunAt: text('last_run_at'), + nextRunAt: text('next_run_at'), + runCount: integer('run_count').notNull().default(0), + lastRunResult: text('last_run_result'), + lastRunError: text('last_run_error'), + createdAt: text('created_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at') + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + projectIdIdx: index('idx_automations_project_id').on(table.projectId), + statusNextRunIdx: index('idx_automations_status_next_run').on(table.status, table.nextRunAt), + updatedAtIdx: index('idx_automations_updated_at').on(table.updatedAt), + }) +); + +export const automationRunLogs = sqliteTable( + 'automation_run_logs', + { + id: text('id').primaryKey(), + automationId: text('automation_id') + .notNull() + .references(() => automations.id, { onDelete: 'cascade' }), + startedAt: text('started_at').notNull(), + finishedAt: text('finished_at'), + status: text('status').notNull(), + error: text('error'), + taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), + }, + (table) => ({ + automationStartedIdx: index('idx_automation_run_logs_automation_started').on( + table.automationId, + table.startedAt + ), + statusIdx: index('idx_automation_run_logs_status').on(table.status), + }) +); + +export type WorkspaceInstanceRow = typeof workspaceInstances.$inferSelect; +export type WorkspaceInstanceInsert = typeof workspaceInstances.$inferInsert; + export const sshConnectionsRelations = relations(sshConnections, ({ many }) => ({ projects: many(projects), + workspaceInstances: many(workspaceInstances), })); export const projectsRelations = relations(projects, ({ one, many }) => ({ @@ -171,6 +259,19 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({ }), conversations: many(conversations), lineComments: many(lineComments), + workspaceInstances: many(workspaceInstances), + automationRunLogs: many(automationRunLogs), +})); + +export const workspaceInstancesRelations = relations(workspaceInstances, ({ one }) => ({ + task: one(tasks, { + fields: [workspaceInstances.taskId], + references: [tasks.id], + }), + sshConnection: one(sshConnections, { + fields: [workspaceInstances.connectionId], + references: [sshConnections.id], + }), })); export const conversationsRelations = relations(conversations, ({ one, many }) => ({ @@ -195,6 +296,25 @@ export const lineCommentsRelations = relations(lineComments, ({ one }) => ({ }), })); +export const automationsRelations = relations(automations, ({ one, many }) => ({ + project: one(projects, { + fields: [automations.projectId], + references: [projects.id], + }), + runLogs: many(automationRunLogs), +})); + +export const automationRunLogsRelations = relations(automationRunLogs, ({ one }) => ({ + automation: one(automations, { + fields: [automationRunLogs.automationId], + references: [automations.id], + }), + task: one(tasks, { + fields: [automationRunLogs.taskId], + references: [tasks.id], + }), +})); + export type SshConnectionRow = typeof sshConnections.$inferSelect; export type SshConnectionInsert = typeof sshConnections.$inferInsert; export type ProjectRow = typeof projects.$inferSelect; @@ -203,3 +323,7 @@ export type ConversationRow = typeof conversations.$inferSelect; export type MessageRow = typeof messages.$inferSelect; export type LineCommentRow = typeof lineComments.$inferSelect; export type LineCommentInsert = typeof lineComments.$inferInsert; +export type AutomationRow = typeof automations.$inferSelect; +export type AutomationInsert = typeof automations.$inferInsert; +export type AutomationRunLogRow = typeof automationRunLogs.$inferSelect; +export type AutomationRunLogInsert = typeof automationRunLogs.$inferInsert; diff --git a/src/main/entry.ts b/src/main/entry.ts index 7e3ec37b02..832490b4de 100644 --- a/src/main/entry.ts +++ b/src/main/entry.ts @@ -2,6 +2,19 @@ // This avoids '@shared/*' resolution failures in the compiled Electron main process. import path from 'node:path'; +// Initialize locale environment BEFORE Electron loads. +// initializeShellEnvironment() sets LANG/LC_ALL/LC_CTYPE to a UTF-8 locale +// if not already set. This MUST happen before require('electron') because +// Electron initializes ICU (libicucore) on load - changing locale env vars +// afterwards causes a crash in AppKit on macOS 26+ during menu init. +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { initializeShellEnvironment } = require('./utils/shellEnv'); + initializeShellEnvironment(); +} catch (err) { + process.stderr.write(`[entry.ts] Failed to initializeShellEnvironment: ${err}\n`); +} + // Ensure app name is set BEFORE any module reads app.getPath('userData'). // In dev builds, if userData is resolved before app name is set, Electron defaults to // ~/Library/Application Support/Electron which leads to confusing "missing DB/migrations" behavior. @@ -37,6 +50,15 @@ try { }; } catch {} +// Ignore EPIPE errors on stdout/stderr (happens when terminal pipe closes +// while the app is still writing logs - would otherwise throw an uncaught exception). +process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code !== 'EPIPE') throw err; +}); +process.stderr.on('error', (err: NodeJS.ErrnoException) => { + if (err.code !== 'EPIPE') throw err; +}); + // Load the actual application bootstrap // eslint-disable-next-line @typescript-eslint/no-var-requires require('./main'); diff --git a/src/main/ipc/accountIpc.ts b/src/main/ipc/accountIpc.ts new file mode 100644 index 0000000000..cba44c76c9 --- /dev/null +++ b/src/main/ipc/accountIpc.ts @@ -0,0 +1,65 @@ +import { ipcMain, BrowserWindow } from 'electron'; +import { emdashAccountService } from '../services/EmdashAccountService'; +import { githubService } from '../services/GitHubService'; +import { log } from '../lib/logger'; + +export function registerAccountIpc() { + ipcMain.handle('account:getSession', async () => { + try { + return { success: true, data: emdashAccountService.getSession() }; + } catch (error) { + log.error('account:getSession failed:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('account:signIn', async () => { + try { + const result = await emdashAccountService.signIn(); + if (result.providerId === 'github') { + await githubService.storeTokenFromOAuth(result.accessToken); + } + + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const win = windows[0]; + if (win.isMinimized()) win.restore(); + win.focus(); + } + + return { success: true, data: { user: result.user } }; + } catch (error) { + log.error('account:signIn failed:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('account:signOut', async () => { + try { + await emdashAccountService.signOut(); + return { success: true }; + } catch (error) { + log.error('account:signOut failed:', error); + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('account:checkServerHealth', async () => { + try { + const available = await emdashAccountService.checkServerHealth(); + return { success: true, data: { available } }; + } catch (error) { + return { success: true, data: { available: false } }; + } + }); + + ipcMain.handle('account:validateSession', async () => { + try { + const valid = await emdashAccountService.validateSession(); + return { success: true, data: { valid } }; + } catch (error) { + log.error('account:validateSession failed:', error); + return { success: false, error: (error as Error).message }; + } + }); +} diff --git a/src/main/ipc/appIpc.ts b/src/main/ipc/appIpc.ts index b43c64df1a..fb445fb62c 100644 --- a/src/main/ipc/appIpc.ts +++ b/src/main/ipc/appIpc.ts @@ -1,4 +1,4 @@ -import { app, clipboard, ipcMain, shell } from 'electron'; +import { app, BrowserWindow, clipboard, ipcMain, shell } from 'electron'; import { exec, execFile } from 'child_process'; import { readFile } from 'fs/promises'; import { join } from 'path'; @@ -7,6 +7,7 @@ import { getAppSettings } from '../settings'; import { getAppById, getResolvedLabel, + isOpenInAppSupportedForWorkspace, OPEN_IN_APPS, type OpenInAppId, type PlatformKey, @@ -198,6 +199,17 @@ const getCachedAppVersion = (): Promise => { export function registerAppIpc() { void getCachedAppVersion(); + let lastBadgeCount = 0; + ipcMain.on('app:set-badge-count', (_event, count: number) => { + if (count === lastBadgeCount) return; + lastBadgeCount = count; + try { + app.setBadgeCount(count); + } catch { + // setBadgeCount is unsupported on some Linux desktop environments + } + }); + ipcMain.handle('app:undo', async (event) => { try { event.sender.undo(); @@ -298,8 +310,22 @@ export function registerAppIpc() { return { success: false, error: `${label} is not available on this platform.` }; } + if (!isOpenInAppSupportedForWorkspace(appConfig, isRemote)) { + return { + success: false, + error: `${label} is not available for remote SSH workspaces.`, + }; + } + // Handle remote SSH connections for supported editors and terminals - if (isRemote && sshConnectionId) { + if (isRemote) { + if (!sshConnectionId) { + return { + success: false, + error: `Missing SSH connection for remote ${label} launch.`, + }; + } + try { const connection = await databaseService.getSshConnection(sshConnectionId); if (!connection) { @@ -627,4 +653,26 @@ export function registerAppIpc() { ipcMain.handle('app:getAppVersion', () => getCachedAppVersion()); ipcMain.handle('app:getElectronVersion', () => process.versions.electron); ipcMain.handle('app:getPlatform', () => process.platform); + + // Window controls (used by custom title bar on Windows/Linux) + ipcMain.handle('app:windowMinimize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + win?.minimize(); + }); + ipcMain.handle('app:windowMaximize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (win?.isMaximized()) { + win.unmaximize(); + } else { + win?.maximize(); + } + }); + ipcMain.handle('app:windowClose', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + win?.close(); + }); + ipcMain.handle('app:windowIsMaximized', (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + return win?.isMaximized() ?? false; + }); } diff --git a/src/main/ipc/automationsIpc.ts b/src/main/ipc/automationsIpc.ts new file mode 100644 index 0000000000..2937d10d95 --- /dev/null +++ b/src/main/ipc/automationsIpc.ts @@ -0,0 +1,355 @@ +import { app, BrowserWindow, ipcMain, powerMonitor } from 'electron'; +import { automationsService } from '../services/AutomationsService'; +import { databaseService } from '../services/DatabaseService'; +import { log } from '../lib/logger'; +import type { + Automation, + CreateAutomationInput, + UpdateAutomationInput, +} from '../../shared/automations/types'; + +// --------------------------------------------------------------------------- +// Input validation helpers +// --------------------------------------------------------------------------- + +function assertString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid ${field}: expected non-empty string`); + } +} + +function assertOptionalString(value: unknown, field: string): asserts value is string | undefined { + if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { + throw new Error(`Invalid ${field}: expected non-empty string or undefined`); + } +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function validateCreateInput(args: unknown): asserts args is CreateAutomationInput { + if (!args || typeof args !== 'object') throw new Error('Invalid input: expected object'); + const a = args as Record; + assertString(a.name, 'name'); + assertString(a.projectId, 'projectId'); + assertString(a.prompt, 'prompt'); + assertString(a.agentId, 'agentId'); + if (!a.schedule || typeof a.schedule !== 'object') { + throw new Error('Invalid schedule: expected object'); + } +} + +function validateUpdateInput(args: unknown): asserts args is UpdateAutomationInput { + if (!args || typeof args !== 'object') throw new Error('Invalid input: expected object'); + const a = args as Record; + assertString(a.id, 'id'); + assertOptionalString(a.name, 'name'); + assertOptionalString(a.projectId, 'projectId'); + assertOptionalString(a.prompt, 'prompt'); + assertOptionalString(a.agentId, 'agentId'); + if (a.schedule !== undefined && (typeof a.schedule !== 'object' || a.schedule === null)) { + throw new Error('Invalid schedule: expected object or undefined'); + } + if (a.useWorktree !== undefined && typeof a.useWorktree !== 'boolean') { + throw new Error('Invalid useWorktree: expected boolean or undefined'); + } +} + +function validateIdArg(args: unknown): asserts args is { id: string } { + if (!args || typeof args !== 'object') throw new Error('Invalid input: expected object'); + assertString((args as Record).id, 'id'); +} + +function validateRunLogsArg( + args: unknown +): asserts args is { automationId: string; limit?: number } { + if (!args || typeof args !== 'object') throw new Error('Invalid input: expected object'); + const a = args as Record; + assertString(a.automationId, 'automationId'); + if (a.limit !== undefined && (typeof a.limit !== 'number' || a.limit <= 0)) { + throw new Error('Invalid limit: expected positive number or undefined'); + } +} + +function validateCompleteRunArg(args: unknown): asserts args is { + runLogId: string; + automationId: string; + status: 'success' | 'failure'; + taskId?: string; + error?: string; +} { + if (!args || typeof args !== 'object') throw new Error('Invalid input: expected object'); + const a = args as Record; + assertString(a.runLogId, 'runLogId'); + assertString(a.automationId, 'automationId'); + if (a.status !== 'success' && a.status !== 'failure') { + throw new Error('Invalid status: expected "success" or "failure"'); + } + if (a.taskId !== undefined && typeof a.taskId !== 'string') { + throw new Error('Invalid taskId: expected string or undefined'); + } + if (a.error !== undefined && typeof a.error !== 'string') { + throw new Error('Invalid error: expected string or undefined'); + } +} + +// --------------------------------------------------------------------------- +// Trigger queue — always buffers, renderer pulls when ready. +// Triggers are queued and the renderer drains via automations:drainTriggers +// when its listener is ready. A push hint via webContents.send() tells the +// renderer to drain immediately. +// --------------------------------------------------------------------------- +interface QueuedTrigger { + automation: Automation; + runLogId: string; +} + +const MAX_TRIGGER_QUEUE = 50; +const triggerQueue: QueuedTrigger[] = []; + +/** Queue a trigger and notify the renderer to drain */ +function sendTriggerToRenderer(automation: Automation, runLogId: string): void { + // Always queue first + if (triggerQueue.length >= MAX_TRIGGER_QUEUE) { + const dropped = triggerQueue.shift()!; + log.warn( + `[Automations] Trigger queue full (${MAX_TRIGGER_QUEUE}) — dropping oldest: "${dropped.automation.name}"` + ); + // Mark the dropped run log as failed so it doesn't stay orphaned + void automationsService + .updateRunLog( + dropped.runLogId, + { + status: 'failure', + finishedAt: new Date().toISOString(), + error: 'Dropped due to trigger queue overflow', + }, + dropped.automation.id + ) + .catch((err) => log.error('[Automations] Failed to mark dropped run log as failed:', err)); + } + triggerQueue.push({ automation, runLogId }); + + // Best-effort push notification — if the renderer is listening, it'll drain immediately + const target = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + if (target && !target.isDestroyed()) { + target.webContents.send('automation:trigger-available'); + } +} + +export function registerAutomationsIpc(): void { + // Wire up the scheduler to send triggers to the renderer + automationsService.onTrigger((automation, runLogId) => { + log.info(`[Automations] Sending trigger to renderer for: ${automation.name}`); + sendTriggerToRenderer(automation, runLogId); + }); + + // Reconcile missed runs (app was closed during scheduled time) then start + void automationsService + .reconcileMissedRuns() + .catch((error) => { + log.error('Failed to reconcile missed automation runs:', error); + }) + .finally(() => { + automationsService.start(); + }); + + // When the system resumes from sleep, reconcile any tasks that were missed + // while the machine was suspended. The normal setInterval-based tick may + // fire late or not at all after a long sleep, so we explicitly catch up. + powerMonitor.on('resume', () => { + log.info('[Automations] System resumed from sleep — reconciling missed runs'); + void automationsService.reconcileMissedRunsAfterResume().catch((error) => { + log.error('Failed to reconcile missed automation runs after sleep:', error); + }); + }); + + // Stop scheduler on app quit + app.on('before-quit', () => { + automationsService.stop(); + }); + + // Hint the renderer to drain when a new window is ready + app.on('browser-window-created', (_, window) => { + window.webContents.once('did-finish-load', () => { + if (triggerQueue.length > 0) { + window.webContents.send('automation:trigger-available'); + } + }); + }); + + // ----------------------------------------------------------------------- + // Pull-based trigger drain — renderer calls this when listener is ready + // ----------------------------------------------------------------------- + ipcMain.handle('automations:drainTriggers', () => { + if (triggerQueue.length === 0) return { success: true, data: [] }; + + log.info(`[Automations] Draining ${triggerQueue.length} queued trigger(s)`); + const items = triggerQueue.splice(0).map((item) => ({ + ...item.automation, + _runLogId: item.runLogId, + })); + + return { success: true, data: items }; + }); + + // ----------------------------------------------------------------------- + // CRUD handlers + // ----------------------------------------------------------------------- + + ipcMain.handle('automations:list', async () => { + try { + const automations = await automationsService.list(); + return { success: true, data: automations }; + } catch (error) { + log.error('Failed to list automations:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:get', async (_, args: unknown) => { + try { + validateIdArg(args); + const automation = await automationsService.get(args.id); + return { success: true, data: automation }; + } catch (error) { + log.error('Failed to get automation:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:create', async (_, args: unknown) => { + try { + validateCreateInput(args); + + // Resolve the project name from the DB and validate the project exists + const projects = await databaseService.getProjects(); + const project = projects.find((p) => p.id === args.projectId); + if (!project) { + return { success: false, error: `Unknown projectId: ${args.projectId}` }; + } + + // Set projectName directly — no separate call needed + const automation = await automationsService.create({ + ...args, + projectName: project.name, + }); + return { success: true, data: automation }; + } catch (error) { + log.error('Failed to create automation:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:update', async (_, args: unknown) => { + try { + validateUpdateInput(args); + + // If projectId is being changed, resolve and validate the new project + if (args.projectId) { + const projects = await databaseService.getProjects(); + const project = projects.find((p) => p.id === args.projectId); + if (!project) { + return { success: false, error: `Unknown projectId: ${args.projectId}` }; + } + (args as unknown as Record).projectName = project.name; + } + + const automation = await automationsService.update(args); + return { success: true, data: automation }; + } catch (error) { + log.error('Failed to update automation:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:delete', async (_, args: unknown) => { + try { + validateIdArg(args); + const deleted = await automationsService.delete(args.id); + return { success: true, data: deleted }; + } catch (error) { + log.error('Failed to delete automation:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:toggle', async (_, args: unknown) => { + try { + validateIdArg(args); + const automation = await automationsService.toggleStatus(args.id); + return { success: true, data: automation }; + } catch (error) { + log.error('Failed to toggle automation:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:runLogs', async (_, args: unknown) => { + try { + validateRunLogsArg(args); + const limit = args.limit !== undefined ? Math.min(args.limit, 500) : undefined; + const logs = await automationsService.getRunLogs(args.automationId, limit); + return { success: true, data: logs }; + } catch (error) { + log.error('Failed to get automation run logs:', error); + return { success: false, error: formatError(error) }; + } + }); + + ipcMain.handle('automations:triggerNow', async (_, args: unknown) => { + try { + validateIdArg(args); + const automation = await automationsService.get(args.id); + if (!automation) { + return { success: false, error: 'Automation not found' }; + } + if (automation.mode === 'trigger') { + return { + success: false, + error: 'Run now is only available for scheduled automations', + }; + } + log.info(`[Automations] Manual trigger for: ${automation.name} (${automation.id})`); + + // Create a run log for the manual trigger + const runLogId = await automationsService.createManualRunLog(automation.id); + + // Send trigger to renderer so it creates a task + sendTriggerToRenderer(automation, runLogId); + return { success: true, data: automation }; + } catch (error) { + log.error('Failed to trigger automation:', error); + return { success: false, error: formatError(error) }; + } + }); + + // ----------------------------------------------------------------------- + // Run completion tracking — renderer reports back when a run finishes + // ----------------------------------------------------------------------- + + ipcMain.handle('automations:completeRun', async (_, args: unknown) => { + try { + validateCompleteRunArg(args); + + await automationsService.updateRunLog( + args.runLogId, + { + status: args.status, + finishedAt: new Date().toISOString(), + taskId: args.taskId ?? null, + error: args.error ?? null, + }, + args.automationId + ); + + await automationsService.setLastRunResult(args.automationId, args.status, args.error); + + return { success: true }; + } catch (error) { + log.error('Failed to complete automation run:', error); + return { success: false, error: formatError(error) }; + } + }); +} diff --git a/src/main/ipc/changelogIpc.ts b/src/main/ipc/changelogIpc.ts new file mode 100644 index 0000000000..c3d3916fc2 --- /dev/null +++ b/src/main/ipc/changelogIpc.ts @@ -0,0 +1,7 @@ +import { createRPCController } from '../../shared/ipc/rpc'; +import { changelogService } from '../services/ChangelogService'; + +export const changelogController = createRPCController({ + getLatestEntry: async (args?: { version?: string }) => + changelogService.getLatestEntry(args?.version), +}); diff --git a/src/main/ipc/dbIpc.ts b/src/main/ipc/dbIpc.ts index 317bf943f1..d7bae1544b 100644 --- a/src/main/ipc/dbIpc.ts +++ b/src/main/ipc/dbIpc.ts @@ -33,16 +33,26 @@ export const databaseController = createRPCController({ getConversations: (taskId: string): Promise => databaseService.getConversations(taskId), - getOrCreateDefaultConversation: (taskId: string): Promise => - databaseService.getOrCreateDefaultConversation(taskId), + getOrCreateDefaultConversation: (args: { + taskId: string; + provider?: string; + }): Promise => + databaseService.getOrCreateDefaultConversation(args.taskId, args.provider), createConversation: (args: { taskId: string; title: string; provider?: string; isMain?: boolean; + metadata?: string | null; }): Promise => - databaseService.createConversation(args.taskId, args.title, args.provider, args.isMain), + databaseService.createConversation( + args.taskId, + args.title, + args.provider, + args.isMain, + args.metadata + ), deleteConversation: (conversationId: string): Promise => databaseService.deleteConversation(conversationId), diff --git a/src/main/ipc/forgejoIpc.ts b/src/main/ipc/forgejoIpc.ts new file mode 100644 index 0000000000..e4623fafb4 --- /dev/null +++ b/src/main/ipc/forgejoIpc.ts @@ -0,0 +1,95 @@ +import { ipcMain } from 'electron'; +import { forgejoService } from '../services/ForgejoService'; +import { log } from '../lib/logger'; + +export function registerForgejoIpc() { + ipcMain.handle( + 'forgejo:saveCredentials', + async (_e, args: { instanceUrl: string; token: string }) => { + const instanceUrl = String(args?.instanceUrl || '').trim(); + const token = String(args?.token || '').trim(); + if (!instanceUrl || !token) { + return { success: false, error: 'Instance URL and API token are required.' }; + } + try { + return await forgejoService.saveCredentials(instanceUrl, token); + } catch (error) { + log.error('Forgejo saveCredentials failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to save Forgejo credentials', + }; + } + } + ); + + ipcMain.handle('forgejo:clearCredentials', async () => { + try { + return await forgejoService.clearCredentials(); + } catch (error) { + log.error('Forgejo clearCredentials failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to clear Forgejo credentials', + }; + } + }); + + ipcMain.handle('forgejo:checkConnection', async () => { + try { + return await forgejoService.checkConnection(); + } catch (error) { + log.error('Forgejo checkConnection failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to check Forgejo connection', + }; + } + }); + + ipcMain.handle( + 'forgejo:initialFetch', + async (_e, args: { projectPath?: string; limit?: number }) => { + const projectPath = args?.projectPath; + const limit = + typeof args?.limit === 'number' && Number.isFinite(args.limit) + ? Math.max(1, Math.min(args.limit, 100)) + : 50; + try { + return await forgejoService.initialFetch(projectPath, limit); + } catch (error) { + log.error('Forgejo initialFetch failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch Forgejo issues', + }; + } + } + ); + + ipcMain.handle( + 'forgejo:searchIssues', + async (_e, args: { projectPath?: string; searchTerm: string; limit?: number }) => { + const searchTerm = String(args?.searchTerm || '').trim(); + if (!searchTerm) { + return { success: true, issues: [] }; + } + const projectPath = args?.projectPath; + const limit = + typeof args?.limit === 'number' && Number.isFinite(args.limit) + ? Math.max(1, Math.min(args.limit, 100)) + : 20; + try { + return await forgejoService.searchIssues(projectPath, searchTerm, limit); + } catch (error) { + log.error('Forgejo searchIssues failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to search Forgejo issues', + }; + } + } + ); +} + +export default registerForgejoIpc; diff --git a/src/main/ipc/gitIpc.ts b/src/main/ipc/gitIpc.ts index e588a51eac..d95dd8b247 100644 --- a/src/main/ipc/gitIpc.ts +++ b/src/main/ipc/gitIpc.ts @@ -9,9 +9,7 @@ import { promisify } from 'util'; import { getStatus as gitGetStatus, getFileDiff as gitGetFileDiff, - stageFile as gitStageFile, - stageAllFiles as gitStageAllFiles, - unstageFile as gitUnstageFile, + updateIndex as gitUpdateIndex, revertFile as gitRevertFile, commit as gitCommit, push as gitPush, @@ -22,20 +20,44 @@ import { getCommitFileDiff as gitGetCommitFileDiff, softResetLastCommit as gitSoftResetLastCommit, } from '../services/GitService'; +import type { GitIndexUpdateArgs } from '../../shared/git/types'; import { prGenerationService } from '../services/PrGenerationService'; import { databaseService } from '../services/DatabaseService'; import { injectIssueFooter } from '../lib/prIssueFooter'; import { getCreatePrBodyPlan } from '../lib/prCreateBodyPlan'; import { patchCurrentPrBodyWithIssueFooter } from '../lib/prIssueFooterPatch'; -import { resolveRemoteProjectForWorktreePath } from '../utils/remoteProjectResolver'; +import { getAppSettings } from '../settings'; +import { + resolveRemoteProjectForWorktreePath, + resolveRemoteContext, +} from '../utils/remoteProjectResolver'; import { RemoteGitService } from '../services/RemoteGitService'; import { sshService } from '../services/ssh/SshService'; +import { githubService } from '../services/GitHubService'; const remoteGitService = new RemoteGitService(sshService); const execAsync = promisify(exec); const execFileAsync = promisify(execFile); +async function execGhAsync(command: string, options?: any) { + const env = await githubService.getCliEnvironment(options?.env); + const result = await execAsync(command, { encoding: 'utf8', ...options, env }); + return { + stdout: String(result.stdout), + stderr: String(result.stderr), + }; +} + +async function execGhFileAsync(args: string[], options?: any) { + const env = await githubService.getCliEnvironment(options?.env); + const result = await execFileAsync('gh', args, { encoding: 'utf8', ...options, env }); + return { + stdout: String(result.stdout), + stderr: String(result.stderr), + }; +} + const GIT_STATUS_DEBOUNCE_MS = 500; const supportsRecursiveWatch = process.platform === 'darwin' || process.platform === 'win32'; @@ -57,10 +79,16 @@ type RemoteStatusPollEntry = { }; const remoteStatusPollers = new Map(); +function shouldAutoCloseLinkedIssuesOnPrCreate(): boolean { + return getAppSettings().repository.autoCloseLinkedIssuesOnPrCreate !== false; +} + const ensureRemoteStatusPoller = ( taskPath: string, - connectionId: string + connectionId: string, + remotePath?: string ): { success: true; watchId: string } => { + const gitPath = remotePath || taskPath; const watchId = randomUUID(); const existing = remoteStatusPollers.get(taskPath); if (existing) { @@ -71,7 +99,7 @@ const ensureRemoteStatusPoller = ( const entry: RemoteStatusPollEntry = { intervalId: setInterval(async () => { try { - const changes = await remoteGitService.getStatusDetailed(connectionId, taskPath); + const changes = await remoteGitService.getStatusDetailed(connectionId, gitPath); // Simple hash: join paths + statuses to detect changes const hash = changes.map((c) => `${c.path}:${c.status}:${c.isStaged}`).join('|'); const poller = remoteStatusPollers.get(taskPath); @@ -258,7 +286,10 @@ export function registerGitIpc() { // Auto-stage if nothing staged yet if (hasWorkingChanges && stagedFiles.length === 0) { - await remoteGitService.stageAllFiles(connectionId, taskPath); + await remoteGitService.updateIndex(connectionId, taskPath, { + action: 'stage', + scope: 'all', + }); } // Unstage plan mode artifacts @@ -315,6 +346,7 @@ export function registerGitIpc() { 'additions', 'deletions', 'changedFiles', + 'autoMergeRequest', ]; const fieldsStr = queryFields.join(','); @@ -392,13 +424,17 @@ export function registerGitIpc() { const { title, body, base, head, draft, web, fill } = opts; const outputs: string[] = []; + const autoCloseLinkedIssuesOnPrCreate = shouldAutoCloseLinkedIssuesOnPrCreate(); + // Enrich body with issue footer let prBody = body; - try { - const task = await databaseService.getTaskByPath(taskPath); - prBody = injectIssueFooter(body, task?.metadata); - } catch { - // Non-fatal + if (autoCloseLinkedIssuesOnPrCreate) { + try { + const task = await databaseService.getTaskByPath(taskPath); + prBody = injectIssueFooter(body, task?.metadata); + } catch { + // Non-fatal + } } const { @@ -419,7 +455,10 @@ export function registerGitIpc() { 'status --porcelain --untracked-files=all' ); if (statusResult.stdout?.trim()) { - await remoteGitService.stageAllFiles(connectionId, taskPath); + await remoteGitService.updateIndex(connectionId, taskPath, { + action: 'stage', + scope: 'all', + }); const commitResult = await remoteGitService.commit( connectionId, taskPath, @@ -500,7 +539,7 @@ export function registerGitIpc() { } // Patch body if needed - if (shouldPatchFilledBody && url) { + if (autoCloseLinkedIssuesOnPrCreate && shouldPatchFilledBody && url) { try { const task = await databaseService.getTaskByPath(taskPath); if (task?.metadata) { @@ -547,7 +586,10 @@ export function registerGitIpc() { 'status --porcelain --untracked-files=all' ); if (statusResult.stdout?.trim()) { - await remoteGitService.stageAllFiles(connectionId, taskPath); + await remoteGitService.updateIndex(connectionId, taskPath, { + action: 'stage', + scope: 'all', + }); const commitResult = await remoteGitService.commit( connectionId, taskPath, @@ -583,21 +625,23 @@ export function registerGitIpc() { } } - // Patch PR body with issue footer - try { - const task = await databaseService.getTaskByPath(taskPath); - if (task?.metadata) { - const footer = injectIssueFooter(undefined, task.metadata); - if (footer) { - await remoteGitService.execGh( - connectionId, - taskPath, - `pr edit --body ${quoteGhArg(footer)}` - ); + if (shouldAutoCloseLinkedIssuesOnPrCreate()) { + // Patch PR body with issue footer + try { + const task = await databaseService.getTaskByPath(taskPath); + if (task?.metadata) { + const footer = injectIssueFooter(undefined, task.metadata); + if (footer) { + await remoteGitService.execGh( + connectionId, + taskPath, + `pr edit --body ${quoteGhArg(footer)}` + ); + } } + } catch { + // Non-fatal } - } catch { - // Non-fatal } // Merge @@ -618,143 +662,357 @@ export function registerGitIpc() { return `'${arg.replace(/'/g, "'\\''")}'`; } - ipcMain.handle('git:watch-status', async (_, taskPath: string) => { - const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); - if (remoteProject) { - return ensureRemoteStatusPoller(taskPath, remoteProject.sshConnectionId); + const countStatusBuckets = ( + changes: Array<{ status?: string; isStaged?: boolean }> + ): { staged: number; unstaged: number; untracked: number } => { + let staged = 0; + let unstaged = 0; + let untracked = 0; + for (const change of changes) { + if (change.status === 'untracked') { + untracked += 1; + } else if (change.isStaged) { + staged += 1; + } else { + unstaged += 1; + } } - return ensureGitStatusWatcher(taskPath); - }); + return { staged, unstaged, untracked }; + }; - ipcMain.handle('git:unwatch-status', async (_, taskPath: string, watchId?: string) => { - const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); - if (remoteProject) { - return releaseRemoteStatusPoller(taskPath, watchId); + const collectChangedPaths = (changes: Array<{ path?: string }>): string[] => { + const seen = new Set(); + const files: string[] = []; + for (const change of changes) { + const file = typeof change.path === 'string' ? change.path.trim() : ''; + if (!file || seen.has(file)) continue; + seen.add(file); + files.push(file); } - return releaseGitStatusWatcher(taskPath, watchId); - }); + return files; + }; - // Git: Status (moved from Codex IPC) - ipcMain.handle('git:get-status', async (_, taskPath: string) => { + const getLocalAheadBehind = async ( + taskPath: string + ): Promise<{ ahead: number; behind: number }> => { try { - const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); - if (remoteProject) { - const changes = await remoteGitService.getStatusDetailed( - remoteProject.sshConnectionId, - taskPath - ); - return { success: true, changes }; - } - const changes = await gitGetStatus(taskPath); - return { success: true, changes }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + const { stdout } = await execFileAsync(GIT, ['status', '-sb'], { cwd: taskPath }); + const firstLine = ((stdout || '').split('\n')[0] || '').trim(); + const aheadMatch = firstLine.match(/ahead\s+(\d+)/i); + const behindMatch = firstLine.match(/behind\s+(\d+)/i); + return { + ahead: aheadMatch ? parseInt(aheadMatch[1], 10) || 0 : 0, + behind: behindMatch ? parseInt(behindMatch[1], 10) || 0 : 0, + }; + } catch { + return { ahead: 0, behind: 0 }; } - }); + }; - // Git: Per-file diff (moved from Codex IPC) - ipcMain.handle('git:get-file-diff', async (_, args: { taskPath: string; filePath: string }) => { + const getLocalPrForDeleteRisk = async ( + taskPath: string + ): Promise<{ pr: unknown | null; prKnown: boolean; error?: string }> => { + const queryFields = [ + 'number', + 'url', + 'state', + 'isDraft', + 'title', + 'headRefName', + 'baseRefName', + ]; + const cmd = `gh pr view --json ${queryFields.join(',')} -q .`; try { - const remoteProject = await resolveRemoteProjectForWorktreePath(args.taskPath); - if (remoteProject) { - const diff = await remoteGitService.getFileDiff( - remoteProject.sshConnectionId, - args.taskPath, - args.filePath - ); - return { success: true, diff }; - } - const diff = await gitGetFileDiff(args.taskPath, args.filePath); - return { success: true, diff }; + const { stdout } = await execGhAsync(cmd, { cwd: taskPath }); + const json = (stdout || '').trim(); + if (!json) return { pr: null, prKnown: true }; + return { pr: JSON.parse(json), prKnown: true }; } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + const errObj = error as { stderr?: string; message?: string }; + const msg = (errObj?.stderr || errObj?.message || String(error || '')).trim(); + if (/no pull requests? found|not found|could not resolve to a pull request/i.test(msg)) { + return { pr: null, prKnown: true }; + } + return { pr: null, prKnown: false, error: msg || 'Failed to query pull request status' }; } - }); + }; - // Git: Stage file - ipcMain.handle('git:stage-file', async (_, args: { taskPath: string; filePath: string }) => { - try { - log.info('Staging file:', { taskPath: args.taskPath, filePath: args.filePath }); - const remoteProject = await resolveRemoteProjectForWorktreePath(args.taskPath); - if (remoteProject) { - await remoteGitService.stageFile( - remoteProject.sshConnectionId, - args.taskPath, - args.filePath - ); - } else { - await gitStageFile(args.taskPath, args.filePath); + ipcMain.handle( + 'git:watch-status', + async (_, arg: string | { taskPath: string; taskId?: string }) => { + const taskPath = typeof arg === 'string' ? arg : arg.taskPath; + const taskId = typeof arg === 'string' ? undefined : arg.taskId; + const remote = await resolveRemoteContext(taskPath, taskId); + if (remote) { + return ensureRemoteStatusPoller(taskPath, remote.connectionId, remote.remotePath); } - log.info('File staged successfully:', args.filePath); - return { success: true }; - } catch (error) { - log.error('Failed to stage file:', { filePath: args.filePath, error }); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return ensureGitStatusWatcher(taskPath); } - }); + ); - // Git: Stage all files - ipcMain.handle('git:stage-all-files', async (_, args: { taskPath: string }) => { - try { - log.info('Staging all files:', { taskPath: args.taskPath }); - const remoteProject = await resolveRemoteProjectForWorktreePath(args.taskPath); - if (remoteProject) { - await remoteGitService.stageAllFiles(remoteProject.sshConnectionId, args.taskPath); - } else { - await gitStageAllFiles(args.taskPath); + ipcMain.handle( + 'git:unwatch-status', + async (_, arg: string | { taskPath: string; taskId?: string }, watchId?: string) => { + const taskPath = typeof arg === 'string' ? arg : arg.taskPath; + const taskId = typeof arg === 'string' ? undefined : arg.taskId; + const remote = await resolveRemoteContext(taskPath, taskId); + if (remote) { + return releaseRemoteStatusPoller(taskPath, watchId); } - log.info('All files staged successfully'); - return { success: true }; - } catch (error) { - log.error('Failed to stage all files:', { taskPath: args.taskPath, error }); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return releaseGitStatusWatcher(taskPath, watchId); } - }); + ); - // Git: Unstage file - ipcMain.handle('git:unstage-file', async (_, args: { taskPath: string; filePath: string }) => { - try { - log.info('Unstaging file:', { taskPath: args.taskPath, filePath: args.filePath }); - const remoteProject = await resolveRemoteProjectForWorktreePath(args.taskPath); - if (remoteProject) { - await remoteGitService.unstageFile( - remoteProject.sshConnectionId, - args.taskPath, - args.filePath + // Git: Status (moved from Codex IPC) + ipcMain.handle( + 'git:get-status', + async (_, arg: string | { taskPath: string; taskId?: string }) => { + const taskPath = typeof arg === 'string' ? arg : arg.taskPath; + const taskId = typeof arg === 'string' ? undefined : arg.taskId; + try { + const remote = await resolveRemoteContext(taskPath, taskId); + if (remote) { + const changes = await remoteGitService.getStatusDetailed( + remote.connectionId, + remote.remotePath + ); + return { success: true, changes }; + } + const changes = await gitGetStatus(taskPath); + return { success: true, changes }; + } catch (error) { + log.error('git:get-status error', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); + + ipcMain.handle( + 'git:get-delete-risks', + async ( + _, + args: { targets: Array<{ id: string; taskPath: string }>; includePr?: boolean } + ): Promise<{ + success: boolean; + risks?: Record< + string, + { + staged: number; + unstaged: number; + untracked: number; + files: string[]; + ahead: number; + behind: number; + error?: string; + pr?: unknown | null; + prKnown: boolean; + } + >; + error?: string; + }> => { + const targets = Array.isArray(args?.targets) ? args.targets : []; + const includePr = args?.includePr === true; + if (targets.length === 0) { + return { success: true, risks: {} }; + } + + try { + const entries = await Promise.all( + targets.map(async (target) => { + const risk: { + staged: number; + unstaged: number; + untracked: number; + files: string[]; + ahead: number; + behind: number; + error?: string; + pr?: unknown | null; + prKnown: boolean; + } = { + staged: 0, + unstaged: 0, + untracked: 0, + files: [], + ahead: 0, + behind: 0, + pr: null, + prKnown: false, + }; + + try { + const remoteProject = await resolveRemoteProjectForWorktreePath(target.taskPath); + + const [changesRes, aheadBehindRes, prRes] = await Promise.all([ + (async () => { + if (remoteProject) { + return await remoteGitService.getStatusDetailed( + remoteProject.sshConnectionId, + target.taskPath + ); + } + return await gitGetStatus(target.taskPath); + })(), + (async () => { + if (remoteProject) { + const status = await remoteGitService.getBranchStatus( + remoteProject.sshConnectionId, + target.taskPath + ); + return { ahead: status.ahead, behind: status.behind }; + } + return await getLocalAheadBehind(target.taskPath); + })(), + (async () => { + if (!includePr) return { pr: null, prKnown: false as const, error: undefined }; + if (remoteProject) { + const result = await getPrStatusRemote( + remoteProject.sshConnectionId, + target.taskPath + ); + if (!result.success) { + return { + pr: null, + prKnown: false as const, + error: result.error || 'Failed to query pull request status', + }; + } + return { pr: result.pr ?? null, prKnown: true as const, error: undefined }; + } + return await getLocalPrForDeleteRisk(target.taskPath); + })(), + ]); + + const counts = countStatusBuckets( + changesRes as Array<{ status?: string; isStaged?: boolean }> + ); + risk.staged = counts.staged; + risk.unstaged = counts.unstaged; + risk.untracked = counts.untracked; + risk.files = collectChangedPaths(changesRes as Array<{ path?: string }>); + risk.ahead = aheadBehindRes.ahead || 0; + risk.behind = aheadBehindRes.behind || 0; + risk.pr = prRes.pr; + risk.prKnown = prRes.prKnown; + if (prRes.error) { + risk.error = prRes.error; + } + } catch (error) { + risk.error = error instanceof Error ? error.message : String(error); + } + + return [target.id, risk] as const; + }) ); - } else { - await gitUnstageFile(args.taskPath, args.filePath); + + return { success: true, risks: Object.fromEntries(entries) }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; } - log.info('File unstaged successfully:', args.filePath); - return { success: true }; - } catch (error) { - log.error('Failed to unstage file:', { filePath: args.filePath, error }); - return { success: false, error: error instanceof Error ? error.message : String(error) }; } - }); + ); - // Git: Revert file - ipcMain.handle('git:revert-file', async (_, args: { taskPath: string; filePath: string }) => { - try { - log.info('Reverting file:', { taskPath: args.taskPath, filePath: args.filePath }); - const remoteProject = await resolveRemoteProjectForWorktreePath(args.taskPath); - let result: { action: string }; - if (remoteProject) { - result = await remoteGitService.revertFile( - remoteProject.sshConnectionId, + // Git: Per-file diff (moved from Codex IPC) + ipcMain.handle( + 'git:get-file-diff', + async ( + _, + args: { + taskPath: string; + taskId?: string; + filePath: string; + baseRef?: string; + forceLarge?: boolean; + } + ) => { + try { + const remote = await resolveRemoteContext(args.taskPath, args.taskId); + if (remote) { + const diff = await remoteGitService.getFileDiff( + remote.connectionId, + remote.remotePath, + args.filePath, + args.baseRef, + args.forceLarge + ); + return { success: true, diff }; + } + const diff = await gitGetFileDiff( args.taskPath, - args.filePath + args.filePath, + args.baseRef, + args.forceLarge ); - } else { - result = await gitRevertFile(args.taskPath, args.filePath); + return { success: true, diff }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; } - log.info('File operation completed:', { filePath: args.filePath, action: result.action }); - return { success: true, action: result.action }; - } catch (error) { - log.error('Failed to revert file:', { filePath: args.filePath, error }); - return { success: false, error: error instanceof Error ? error.message : String(error) }; } - }); + ); + + // Git: Update index (stage/unstage all or selected paths) + ipcMain.handle( + 'git:update-index', + async (_, args: { taskPath: string; taskId?: string } & GitIndexUpdateArgs) => { + try { + const operationArgs: GitIndexUpdateArgs = { + action: args.action, + scope: args.scope, + filePaths: args.scope === 'paths' ? (args.filePaths || []).filter(Boolean) : undefined, + }; + if ( + operationArgs.scope === 'paths' && + (!operationArgs.filePaths || operationArgs.filePaths.length === 0) + ) { + return { success: true }; + } + + log.info('Updating git index', { + taskPath: args.taskPath, + action: operationArgs.action, + scope: operationArgs.scope, + count: operationArgs.filePaths?.length ?? null, + }); + + const remote = await resolveRemoteContext(args.taskPath, args.taskId); + if (remote) { + await remoteGitService.updateIndex(remote.connectionId, remote.remotePath, operationArgs); + } else { + await gitUpdateIndex(args.taskPath, operationArgs); + } + return { success: true }; + } catch (error) { + log.error('Failed to update git index', { taskPath: args.taskPath, error }); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); + + // Git: Revert file + ipcMain.handle( + 'git:revert-file', + async (_, args: { taskPath: string; taskId?: string; filePath: string }) => { + try { + log.info('Reverting file:', { taskPath: args.taskPath, filePath: args.filePath }); + const remote = await resolveRemoteContext(args.taskPath, args.taskId); + let result: { action: string }; + if (remote) { + result = await remoteGitService.revertFile( + remote.connectionId, + remote.remotePath, + args.filePath + ); + } else { + result = await gitRevertFile(args.taskPath, args.filePath); + } + log.info('File operation completed:', { filePath: args.filePath, action: result.action }); + return { success: true, action: result.action }; + } catch (error) { + log.error('Failed to revert file:', { filePath: args.filePath, error }); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); // Git: Generate PR title and description ipcMain.handle( 'git:generate-pr-content', @@ -837,9 +1095,10 @@ export function registerGitIpc() { draft?: boolean; web?: boolean; fill?: boolean; + skipPrePush?: boolean; } ) => { - const { taskPath, title, body, base, head, draft, web, fill } = + const { taskPath, title, body, base, head, draft, web, fill, skipPrePush } = args || ({} as { taskPath: string; @@ -850,6 +1109,7 @@ export function registerGitIpc() { draft?: boolean; web?: boolean; fill?: boolean; + skipPrePush?: boolean; }); try { const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); @@ -866,14 +1126,17 @@ export function registerGitIpc() { } const outputs: string[] = []; + const autoCloseLinkedIssuesOnPrCreate = shouldAutoCloseLinkedIssuesOnPrCreate(); let taskMetadata: unknown = undefined; let prBody = body; - try { - const task = await databaseService.getTaskByPath(taskPath); - taskMetadata = task?.metadata; - prBody = injectIssueFooter(body, task?.metadata); - } catch (error) { - log.debug('Unable to enrich PR body with issue footer', { taskPath, error }); + if (autoCloseLinkedIssuesOnPrCreate) { + try { + const task = await databaseService.getTaskByPath(taskPath); + taskMetadata = task?.metadata; + prBody = injectIssueFooter(body, task?.metadata); + } catch (error) { + log.debug('Unable to enrich PR body with issue footer', { taskPath, error }); + } } const { shouldPatchFilledBody, shouldUseBodyFile, shouldUseFill } = getCreatePrBodyPlan({ fill, @@ -882,64 +1145,71 @@ export function registerGitIpc() { enrichedBody: prBody, }); - // Stage and commit any pending changes - try { - const { stdout: statusOut } = await execAsync( - 'git status --porcelain --untracked-files=all', - { - cwd: taskPath, - } - ); - if (statusOut && statusOut.trim().length > 0) { - const { stdout: addOut, stderr: addErr } = await execAsync('git add -A', { - cwd: taskPath, - }); - if (addOut?.trim()) outputs.push(addOut.trim()); - if (addErr?.trim()) outputs.push(addErr.trim()); + // Stage, commit, and push — skip when the caller already did this (skipPrePush) + if (!skipPrePush) { + try { + const { stdout: statusOut } = await execAsync( + 'git status --porcelain --untracked-files=all', + { + cwd: taskPath, + } + ); + if (statusOut && statusOut.trim().length > 0) { + const { stdout: addOut, stderr: addErr } = await execAsync('git add -A', { + cwd: taskPath, + }); + if (addOut?.trim()) outputs.push(addOut.trim()); + if (addErr?.trim()) outputs.push(addErr.trim()); - const commitMsg = 'stagehand: prepare pull request'; - try { - const { stdout: commitOut, stderr: commitErr } = await execAsync( - `git commit -m ${JSON.stringify(commitMsg)}`, - { cwd: taskPath } - ); - if (commitOut?.trim()) outputs.push(commitOut.trim()); - if (commitErr?.trim()) outputs.push(commitErr.trim()); - } catch (commitErr) { - const msg = commitErr instanceof Error ? commitErr.message : String(commitErr); - if (msg && /nothing to commit/i.test(msg)) { - outputs.push('git commit: nothing to commit'); - } else { - throw commitErr; + const commitMsg = 'stagehand: prepare pull request'; + try { + const { stdout: commitOut, stderr: commitErr } = await execAsync( + `git commit -m ${JSON.stringify(commitMsg)}`, + { cwd: taskPath } + ); + if (commitOut?.trim()) outputs.push(commitOut.trim()); + if (commitErr?.trim()) outputs.push(commitErr.trim()); + } catch (commitErr) { + const msg = commitErr instanceof Error ? commitErr.message : String(commitErr); + if (msg && /nothing to commit/i.test(msg)) { + outputs.push('git commit: nothing to commit'); + } else { + throw commitErr; + } } } + } catch (stageErr) { + const stageMsg = stageErr instanceof Error ? stageErr.message : String(stageErr); + if (/nothing to commit/i.test(stageMsg)) { + outputs.push('git: nothing to commit'); + } else { + log.error('Failed to stage/commit changes before PR:', stageMsg); + throw stageErr; + } } - } catch (stageErr) { - log.warn('Failed to stage/commit changes before PR:', stageErr as string); - // Continue; PR may still be created for existing commits - } - // Ensure branch is pushed to origin so PR includes latest commit - try { - await execAsync('git push', { cwd: taskPath }); - outputs.push('git push: success'); - } catch (pushErr) { + // Ensure branch is pushed to origin so PR includes latest commit try { - const { stdout: branchOut } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: taskPath, - }); - const branch = branchOut.trim(); - await execAsync(`git push --set-upstream origin ${JSON.stringify(branch)}`, { - cwd: taskPath, - }); - outputs.push(`git push --set-upstream origin ${branch}: success`); - } catch (pushErr2) { - log.error('Failed to push branch before PR:', pushErr2 as string); - return { - success: false, - error: - 'Failed to push branch to origin. Please check your Git remotes and authentication.', - }; + await execAsync('git push', { cwd: taskPath }); + outputs.push('git push: success'); + } catch (pushErr) { + try { + const { stdout: branchOut } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: taskPath, + }); + const branch = branchOut.trim(); + await execAsync(`git push --set-upstream origin ${JSON.stringify(branch)}`, { + cwd: taskPath, + }); + outputs.push(`git push --set-upstream origin ${branch}: success`); + } catch (pushErr2) { + log.error('Failed to push branch before PR:', pushErr2 as string); + return { + success: false, + error: + 'Failed to push branch to origin. Please check your Git remotes and authentication.', + }; + } } } @@ -951,7 +1221,7 @@ export function registerGitIpc() { } catch {} let defaultBranch = 'main'; try { - const { stdout } = await execAsync( + const { stdout } = await execGhAsync( 'gh repo view --json defaultBranchRef -q .defaultBranchRef.name', { cwd: taskPath } ); @@ -964,7 +1234,7 @@ export function registerGitIpc() { { cwd: taskPath } ); const db2 = (stdout || '').trim(); - if (db2) defaultBranch = db2; + if (db2 && db2 !== '(unknown)') defaultBranch = db2; } catch {} } @@ -1026,7 +1296,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, let stdout: string; let stderr: string; try { - const result = await execAsync(cmd, { cwd: taskPath }); + const result = await execGhAsync(cmd, { cwd: taskPath }); stdout = result.stdout || ''; stderr = result.stderr || ''; } finally { @@ -1047,12 +1317,13 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, const urlMatch = out.match(/https?:\/\/\S+/); const url = urlMatch ? urlMatch[0] : null; - if (shouldPatchFilledBody) { + if (autoCloseLinkedIssuesOnPrCreate && shouldPatchFilledBody) { try { const didPatchBody = await patchCurrentPrBodyWithIssueFooter({ taskPath, metadata: taskMetadata, - execFile: execFileAsync, + execFile: (file, args, options) => + file === 'gh' ? execGhFileAsync(args, options) : execFileAsync(file, args, options), prUrl: url, }); if (didPatchBody) { @@ -1125,24 +1396,36 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, 'additions', 'deletions', 'changedFiles', + 'autoMergeRequest', ]; const cmd = `gh pr view --json ${queryFields.join(',')} -q .`; try { - const { stdout } = await execAsync(cmd, { cwd: taskPath }); - const json = (stdout || '').trim(); - let data = json ? JSON.parse(json) : null; + // Attempt 1: gh pr view (works when branch tracking points to a PR) + let data: any = null; + try { + const { stdout } = await execGhAsync(cmd, { cwd: taskPath }); + const json = (stdout || '').trim(); + data = json ? JSON.parse(json) : null; + } catch (viewErr) { + const viewMsg = String(viewErr); + // Re-throw unexpected errors; swallow "no PR found" so fallbacks can run + if (!/no pull requests? found/i.test(viewMsg) && !/not found/i.test(viewMsg)) { + throw viewErr; + } + } // Fallback: If gh pr view didn't find a PR (e.g. detached head, upstream not set, or fresh branch), // try finding it by branch name via gh pr list. + let currentBranch = ''; if (!data) { try { const { stdout: branchOut } = await execAsync('git branch --show-current', { cwd: taskPath, }); - const currentBranch = branchOut.trim(); + currentBranch = branchOut.trim(); if (currentBranch) { const listCmd = `gh pr list --head ${JSON.stringify(currentBranch)} --json ${queryFields.join(',')} --limit 1`; - const { stdout: listOut } = await execAsync(listCmd, { cwd: taskPath }); + const { stdout: listOut } = await execGhAsync(listCmd, { cwd: taskPath }); const listJson = (listOut || '').trim(); const listData = listJson ? JSON.parse(listJson) : []; if (listData.length > 0) { @@ -1155,7 +1438,37 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } } - if (!data) return { success: false, error: 'No PR data returned' }; + // Fork fallback: if still no PR found, check if this repo is a fork and search + // the parent/upstream repo for a PR with head ":". + if (!data && currentBranch) { + try { + const { stdout: repoOut } = await execGhAsync('gh repo view --json owner,parent', { + cwd: taskPath, + }); + const repoData = repoOut.trim() ? JSON.parse(repoOut.trim()) : null; + const parentOwner = repoData?.parent?.owner?.login; + const parentName = repoData?.parent?.name; + const parentRepo = parentOwner && parentName ? `${parentOwner}/${parentName}` : null; + if (parentRepo) { + const forkOwnerLogin = repoData?.owner?.login; + const forkFields = [...queryFields, 'headRepositoryOwner']; + const forkListCmd = `gh pr list --head ${JSON.stringify(currentBranch)} --repo ${JSON.stringify(parentRepo)} --state open --json ${forkFields.join(',')} --limit 10`; + const { stdout: forkOut } = await execGhAsync(forkListCmd, { cwd: taskPath }); + const forkJson = (forkOut || '').trim(); + const forkData = forkJson ? JSON.parse(forkJson) : []; + const match = forkOwnerLogin + ? forkData.find((pr: any) => pr?.headRepositoryOwner?.login === forkOwnerLogin) + : forkData[0]; + if (match) { + data = match; + } + } + } catch (forkFallbackErr) { + log.warn('Failed to check fork parent for PR:', forkFallbackErr); + } + } + + if (!data) return { success: true, pr: null }; // Fallback: if GH CLI didn't return diff stats, try to compute locally const asNumber = (v: any): number | null => @@ -1272,7 +1585,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, if (admin) ghArgs.push('--admin'); try { - const { stdout, stderr } = await execFileAsync('gh', ghArgs, { cwd: taskPath }); + const { stdout, stderr } = await execGhFileAsync(ghArgs, { cwd: taskPath }); const output = [stdout, stderr].filter(Boolean).join('\n').trim(); return { success: true, output }; } catch (err) { @@ -1291,6 +1604,108 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } ); + // Git: Enable auto-merge on a PR via GitHub CLI + ipcMain.handle( + 'git:enable-auto-merge', + async ( + _, + args: { + taskPath: string; + prNumber?: number; + strategy?: 'merge' | 'squash' | 'rebase'; + } + ) => { + const { + taskPath, + prNumber, + strategy = 'merge', + } = (args || {}) as { + taskPath: string; + prNumber?: number; + strategy?: 'merge' | 'squash' | 'rebase'; + }; + + try { + const strategyFlag = + strategy === 'squash' ? '--squash' : strategy === 'rebase' ? '--rebase' : '--merge'; + const ghArgs = ['pr', 'merge']; + if (typeof prNumber === 'number' && Number.isFinite(prNumber)) { + ghArgs.push(String(prNumber)); + } + ghArgs.push('--auto', strategyFlag); + + const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); + if (remoteProject) { + const result = await remoteGitService.execGh( + remoteProject.sshConnectionId, + taskPath, + ghArgs.join(' ') + ); + if (result.exitCode !== 0) { + return { success: false, error: (result.stderr || result.stdout || '').trim() }; + } + return { success: true }; + } + + await execFileAsync(GIT, ['rev-parse', '--is-inside-work-tree'], { cwd: taskPath }); + const { stdout, stderr } = await execGhFileAsync(ghArgs, { cwd: taskPath }); + const output = [stdout, stderr].filter(Boolean).join('\n').trim(); + return { success: true, output }; + } catch (error) { + const stderr = (error as any)?.stderr; + const msg = stderr || (error instanceof Error ? error.message : String(error)); + return { success: false, error: typeof msg === 'string' ? msg.trim() : String(msg) }; + } + } + ); + + // Git: Disable auto-merge on a PR via GitHub CLI + ipcMain.handle( + 'git:disable-auto-merge', + async ( + _, + args: { + taskPath: string; + prNumber?: number; + } + ) => { + const { taskPath, prNumber } = (args || {}) as { + taskPath: string; + prNumber?: number; + }; + + try { + const ghArgs = ['pr', 'merge']; + if (typeof prNumber === 'number' && Number.isFinite(prNumber)) { + ghArgs.push(String(prNumber)); + } + ghArgs.push('--disable-auto'); + + const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); + if (remoteProject) { + const result = await remoteGitService.execGh( + remoteProject.sshConnectionId, + taskPath, + ghArgs.join(' ') + ); + if (result.exitCode !== 0) { + return { success: false, error: (result.stderr || result.stdout || '').trim() }; + } + return { success: true }; + } + + await execFileAsync(GIT, ['rev-parse', '--is-inside-work-tree'], { cwd: taskPath }); + const { stdout, stderr } = await execGhFileAsync(ghArgs, { cwd: taskPath }); + const output = [stdout, stderr].filter(Boolean).join('\n').trim(); + return { success: true, output }; + } catch (error) { + const stderr = (error as any)?.stderr; + const msg = stderr || (error instanceof Error ? error.message : String(error)); + return { success: false, error: typeof msg === 'string' ? msg.trim() : String(msg) }; + } + } + ); + // Git: Get CI/CD check runs for current branch via GitHub CLI ipcMain.handle('git:get-check-runs', async (_, args: { taskPath: string }) => { const { taskPath } = args || ({} as { taskPath: string }); @@ -1299,11 +1714,57 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, if (remoteProject) { const connId = remoteProject.sshConnectionId; const fields = 'bucket,completedAt,description,event,link,name,startedAt,state,workflow'; - const checksResult = await remoteGitService.execGh( - connId, - taskPath, - `pr checks --json ${fields}` - ); + + // Detect fork on remote: find PR in parent repo if applicable + let prRef: string | null = null; + let remoteParentRepo: string | null = null; + let remoteChecksApiRepo = 'repos/{owner}/{repo}'; + let remoteHeadRefOidCmd = "pr view --json headRefOid --jq '.headRefOid'"; + try { + const repoResult = await remoteGitService.execGh( + connId, + taskPath, + 'repo view --json owner,parent' + ); + const repoData = repoResult.stdout.trim() ? JSON.parse(repoResult.stdout.trim()) : null; + const parentOwner = repoData?.parent?.owner?.login; + const parentName = repoData?.parent?.name; + const parentRepo = parentOwner && parentName ? `${parentOwner}/${parentName}` : null; + if (parentRepo) { + const branchResult = await remoteGitService.execGit( + connId, + taskPath, + 'branch --show-current' + ); + const currentBranch = branchResult.stdout.trim(); + if (currentBranch) { + const listResult = await remoteGitService.execGh( + connId, + taskPath, + `pr list --head ${quoteGhArg(currentBranch)} --repo ${quoteGhArg(parentRepo)} --state open --json number,headRefOid,headRepositoryOwner --limit 10` + ); + const forkOwnerLogin = repoData?.owner?.login; + const listData = listResult.stdout.trim() ? JSON.parse(listResult.stdout.trim()) : []; + const matched = forkOwnerLogin + ? listData.find((pr: any) => pr?.headRepositoryOwner?.login === forkOwnerLogin) + : listData[0]; + if (matched) { + prRef = String(matched.number); + remoteParentRepo = parentRepo; + remoteChecksApiRepo = `repos/${parentRepo}`; + remoteHeadRefOidCmd = `pr view ${prRef} --repo ${quoteGhArg(parentRepo)} --json headRefOid --jq '.headRefOid'`; + } + } + } + } catch { + // Not a fork or detection failed — proceed with default behavior + } + + const checksCmd = + prRef && remoteParentRepo + ? `pr checks ${prRef} --repo ${quoteGhArg(remoteParentRepo)} --json ${fields}` + : `pr checks --json ${fields}`; + const checksResult = await remoteGitService.execGh(connId, taskPath, checksCmd); if (checksResult.exitCode !== 0) { const msg = checksResult.stderr || ''; if (/no pull requests? found/i.test(msg) || /not found/i.test(msg)) { @@ -1318,17 +1779,13 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, // Fetch html_url from API try { - const shaResult = await remoteGitService.execGh( - connId, - taskPath, - "pr view --json headRefOid --jq '.headRefOid'" - ); + const shaResult = await remoteGitService.execGh(connId, taskPath, remoteHeadRefOidCmd); const sha = shaResult.stdout.trim(); if (sha) { const apiResult = await remoteGitService.execGh( connId, taskPath, - `api repos/{owner}/{repo}/commits/${sha}/check-runs --jq '.check_runs | map({name: .name, html_url: .html_url}) | .[]'` + `api ${remoteChecksApiRepo}/commits/${sha}/check-runs --jq '.check_runs | map({name: .name, html_url: .html_url}) | .[]'` ); const urlMap = new Map(); for (const line of apiResult.stdout.trim().split('\n')) { @@ -1353,28 +1810,92 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, await execFileAsync(GIT, ['rev-parse', '--is-inside-work-tree'], { cwd: taskPath }); const fields = 'bucket,completedAt,description,event,link,name,startedAt,state,workflow'; + + // Resolve the PR number and repo to use for gh commands. + // For forks, the PR lives in the parent repo, so we detect that here once. + let prRef: string | null = null; // PR number as string, or null to use implicit (current branch) + let repoFlag: string[] = []; // ['--repo', 'owner/repo'] or empty + let headRefOidArgs: string[] = ['pr', 'view', '--json', 'headRefOid', '--jq', '.headRefOid']; + let checkRunsApiRepo = 'repos/{owner}/{repo}'; // used in gh api call + try { - const { stdout } = await execFileAsync('gh', ['pr', 'checks', '--json', fields], { - cwd: taskPath, - }); + // Detect fork: if this repo has a parent, find the PR number there + const { stdout: repoOut } = await execGhFileAsync( + ['repo', 'view', '--json', 'owner,parent'], + { cwd: taskPath } + ); + const repoData = repoOut.trim() ? JSON.parse(repoOut.trim()) : null; + const parentOwner = repoData?.parent?.owner?.login; + const parentName = repoData?.parent?.name; + const parentRepo = parentOwner && parentName ? `${parentOwner}/${parentName}` : null; + if (parentRepo) { + const { stdout: branchOut } = await execFileAsync('git', ['branch', '--show-current'], { + cwd: taskPath, + }); + const currentBranch = branchOut.trim(); + if (currentBranch) { + const { stdout: listOut } = await execGhFileAsync( + [ + 'pr', + 'list', + '--head', + currentBranch, + '--repo', + parentRepo, + '--state', + 'open', + '--json', + 'number,headRefOid,headRepositoryOwner', + '--limit', + '10', + ], + { cwd: taskPath } + ); + const forkOwnerLogin = repoData?.owner?.login; + const listData = listOut.trim() ? JSON.parse(listOut.trim()) : []; + const matched = forkOwnerLogin + ? listData.find((pr: any) => pr?.headRepositoryOwner?.login === forkOwnerLogin) + : listData[0]; + if (matched) { + prRef = String(matched.number); + repoFlag = ['--repo', parentRepo]; + headRefOidArgs = [ + 'pr', + 'view', + prRef, + '--repo', + parentRepo, + '--json', + 'headRefOid', + '--jq', + '.headRefOid', + ]; + checkRunsApiRepo = `repos/${parentRepo}`; + } + } + } + } catch { + // Not a fork or detection failed — proceed with default (current-branch) behavior + } + + try { + const checksArgs = prRef + ? ['pr', 'checks', prRef, ...repoFlag, '--json', fields] + : ['pr', 'checks', '--json', fields]; + const { stdout } = await execGhFileAsync(checksArgs, { cwd: taskPath }); const json = (stdout || '').trim(); const checks = json ? JSON.parse(json) : []; // Fetch html_url from the GitHub API instead, which always points to the // actual check run page on GitHub. try { - const { stdout: shaOut } = await execFileAsync( - 'gh', - ['pr', 'view', '--json', 'headRefOid', '--jq', '.headRefOid'], - { cwd: taskPath } - ); + const { stdout: shaOut } = await execGhFileAsync(headRefOidArgs, { cwd: taskPath }); const sha = shaOut.trim(); if (sha) { - const { stdout: apiOut } = await execFileAsync( - 'gh', + const { stdout: apiOut } = await execGhFileAsync( [ 'api', - `repos/{owner}/{repo}/commits/${sha}/check-runs`, + `${checkRunsApiRepo}/commits/${sha}/check-runs`, '--jq', '.check_runs | map({name: .name, html_url: .html_url}) | .[]', ], @@ -1498,7 +2019,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, if (prNumber) ghArgs.push(String(prNumber)); ghArgs.push('--json', 'comments,reviews,number'); - const { stdout } = await execFileAsync('gh', ghArgs, { cwd: taskPath }); + const { stdout } = await execGhFileAsync(ghArgs, { cwd: taskPath }); const json = (stdout || '').trim(); const data = json ? JSON.parse(json) : { comments: [], reviews: [], number: 0 }; @@ -1511,8 +2032,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, try { const avatarMap = new Map(); - const { stdout: commentsApi } = await execFileAsync( - 'gh', + const { stdout: commentsApi } = await execGhFileAsync( [ 'api', `repos/{owner}/{repo}/issues/${data.number}/comments`, @@ -1535,8 +2055,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } catch {} } - const { stdout: reviewsApi } = await execFileAsync( - 'gh', + const { stdout: reviewsApi } = await execGhFileAsync( [ 'api', `repos/{owner}/{repo}/pulls/${data.number}/reviews`, @@ -1588,6 +2107,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, _, args: { taskPath: string; + taskId?: string; commitMessage?: string; createBranchIfOnDefault?: boolean; branchPrefix?: string; @@ -1595,27 +2115,30 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, ) => { const { taskPath, + taskId, commitMessage = 'chore: apply task changes', createBranchIfOnDefault = true, branchPrefix = 'orch', } = (args || ({} as { taskPath: string; + taskId?: string; commitMessage?: string; createBranchIfOnDefault?: boolean; branchPrefix?: string; })) as { taskPath: string; + taskId?: string; commitMessage?: string; createBranchIfOnDefault?: boolean; branchPrefix?: string; }; try { - const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); + const remote = await resolveRemoteContext(taskPath, taskId); - if (remoteProject) { - return await commitAndPushRemote(remoteProject.sshConnectionId, taskPath, { + if (remote) { + return await commitAndPushRemote(remote.connectionId, remote.remotePath, { commitMessage, createBranchIfOnDefault, branchPrefix, @@ -1634,7 +2157,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, // Determine default branch via gh, fallback to main/master let defaultBranch = 'main'; try { - const { stdout } = await execAsync( + const { stdout } = await execGhAsync( 'gh repo view --json defaultBranchRef -q .defaultBranchRef.name', { cwd: taskPath } ); @@ -1647,7 +2170,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, { cwd: taskPath } ); const db2 = (stdout || '').trim(); - if (db2) defaultBranch = db2; + if (db2 && db2 !== '(unknown)') defaultBranch = db2; } catch {} } @@ -1729,101 +2252,99 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, return { success: true, branch: activeBranch, output: (out || '').trim() }; } catch (error) { log.error('Failed to commit and push:', error); - const errObj = error as { stderr?: string; message?: string }; - const errMsg = errObj?.stderr?.trim() || errObj?.message || String(error); - return { success: false, error: errMsg }; + const errObj = error as { stderr?: string; stdout?: string; message?: string }; + // Hooks (husky/lint-staged etc.) often write failure details to stdout, not stderr. + const stderr = errObj?.stderr?.trim() ?? ''; + const stdout = errObj?.stdout?.trim() ?? ''; + const combined = [stdout, stderr].filter(Boolean).join('\n').trim(); + return { + success: false, + error: combined || errObj?.message || String(error), + }; } } ); // Git: Get branch status (current branch, default branch, ahead/behind counts) - ipcMain.handle('git:get-branch-status', async (_, args: { taskPath: string }) => { - const { taskPath } = args || ({} as { taskPath: string }); - - if (!taskPath) { - return { success: false, error: 'Path does not exist' }; - } + ipcMain.handle( + 'git:get-branch-status', + async (_, args: { taskPath: string; taskId?: string }) => { + const { taskPath, taskId } = args || ({} as { taskPath: string; taskId?: string }); - const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); - if (remoteProject) { - try { - const status = await remoteGitService.getBranchStatus( - remoteProject.sshConnectionId, - taskPath - ); - return { success: true, ...status }; - } catch (error) { - log.error(`getBranchStatus (remote): error for ${taskPath}:`, error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + if (!taskPath) { + return { success: false, error: 'Path does not exist' }; } - } - - // Early exit for missing/invalid local path - if (!fs.existsSync(taskPath)) { - log.warn(`getBranchStatus: path does not exist: ${taskPath}`); - return { success: false, error: 'Path does not exist' }; - } - // Check if it's a git repo - expected to fail often for non-git paths - try { - await execFileAsync(GIT, ['rev-parse', '--is-inside-work-tree'], { cwd: taskPath }); - } catch { - log.warn(`getBranchStatus: not a git repository: ${taskPath}`); - return { success: false, error: 'Not a git repository' }; - } + const remote = await resolveRemoteContext(taskPath, taskId); + if (remote) { + try { + const status = await remoteGitService.getBranchStatus( + remote.connectionId, + remote.remotePath + ); + return { success: true, ...status }; + } catch (error) { + log.error(`getBranchStatus (remote): error for ${taskPath}:`, error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } - try { - // Current branch - const { stdout: currentBranchOut } = await execFileAsync(GIT, ['branch', '--show-current'], { - cwd: taskPath, - }); - const branch = (currentBranchOut || '').trim(); + // Early exit for missing/invalid local path + if (!fs.existsSync(taskPath)) { + log.warn(`getBranchStatus: path does not exist: ${taskPath}`); + return { success: false, error: 'Path does not exist' }; + } - // Determine default branch - let defaultBranch = 'main'; + // Check if it's a git repo - expected to fail often for non-git paths try { - const { stdout } = await execFileAsync( - 'gh', - ['repo', 'view', '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name'], - { cwd: taskPath } - ); - const db = (stdout || '').trim(); - if (db) defaultBranch = db; + await execFileAsync(GIT, ['rev-parse', '--is-inside-work-tree'], { cwd: taskPath }); } catch { - try { - // Use symbolic-ref to resolve origin/HEAD then take the last path part - const { stdout } = await execFileAsync( - GIT, - ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], - { cwd: taskPath } - ); - const line = (stdout || '').trim(); - const last = line.split('/').pop(); - if (last) defaultBranch = last; - } catch {} + log.warn(`getBranchStatus: not a git repository: ${taskPath}`); + return { success: false, error: 'Not a git repository' }; } - // Ahead/behind relative to upstream tracking branch - let ahead = 0; - let behind = 0; try { - // Best case: compare against the upstream tracking branch (@{upstream}) - const { stdout } = await execFileAsync( + // Current branch + const { stdout: currentBranchOut } = await execFileAsync( GIT, - ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], - { cwd: taskPath } + ['branch', '--show-current'], + { + cwd: taskPath, + } ); - const parts = (stdout || '').trim().split(/\s+/); - if (parts.length >= 2) { - behind = parseInt(parts[0] || '0', 10) || 0; - ahead = parseInt(parts[1] || '0', 10) || 0; + const branch = (currentBranchOut || '').trim(); + + // Determine default branch + let defaultBranch = 'main'; + try { + const { stdout } = await execGhFileAsync( + ['repo', 'view', '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name'], + { cwd: taskPath } + ); + const db = (stdout || '').trim(); + if (db) defaultBranch = db; + } catch { + try { + // Use symbolic-ref to resolve origin/HEAD then take the last path part + const { stdout } = await execFileAsync( + GIT, + ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], + { cwd: taskPath } + ); + const line = (stdout || '').trim(); + const last = line.split('/').pop(); + if (last) defaultBranch = last; + } catch {} } - } catch { + + // Ahead/behind relative to upstream tracking branch + let ahead = 0; + let behind = 0; try { - // Fallback: compare against origin/ + // Best case: compare against the upstream tracking branch (@{upstream}) const { stdout } = await execFileAsync( GIT, - ['rev-list', '--left-right', '--count', `origin/${branch}...HEAD`], + ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], { cwd: taskPath } ); const parts = (stdout || '').trim().split(/\s+/); @@ -1832,39 +2353,53 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, ahead = parseInt(parts[1] || '0', 10) || 0; } } catch { - // No upstream — use git status as last resort try { - const { stdout } = await execFileAsync(GIT, ['status', '-sb'], { cwd: taskPath }); - const line = (stdout || '').split(/\n/)[0] || ''; - const m = line.match(/ahead\s+(\d+)/i); - const n = line.match(/behind\s+(\d+)/i); - if (m) ahead = parseInt(m[1] || '0', 10) || 0; - if (n) behind = parseInt(n[1] || '0', 10) || 0; - } catch {} + // Fallback: compare against origin/ + const { stdout } = await execFileAsync( + GIT, + ['rev-list', '--left-right', '--count', `origin/${branch}...HEAD`], + { cwd: taskPath } + ); + const parts = (stdout || '').trim().split(/\s+/); + if (parts.length >= 2) { + behind = parseInt(parts[0] || '0', 10) || 0; + ahead = parseInt(parts[1] || '0', 10) || 0; + } + } catch { + // No upstream — use git status as last resort + try { + const { stdout } = await execFileAsync(GIT, ['status', '-sb'], { cwd: taskPath }); + const line = (stdout || '').split(/\n/)[0] || ''; + const m = line.match(/ahead\s+(\d+)/i); + const n = line.match(/behind\s+(\d+)/i); + if (m) ahead = parseInt(m[1] || '0', 10) || 0; + if (n) behind = parseInt(n[1] || '0', 10) || 0; + } catch {} + } } - } - // Count commits ahead of origin/ (for PR visibility) - let aheadOfDefault = 0; - if (branch !== defaultBranch) { - try { - const { stdout: countOut } = await execFileAsync( - GIT, - ['rev-list', '--count', `origin/${defaultBranch}..HEAD`], - { cwd: taskPath } - ); - aheadOfDefault = parseInt(countOut.trim(), 10) || 0; - } catch { - // origin/ may not exist + // Count commits ahead of origin/ (for PR visibility) + let aheadOfDefault = 0; + if (branch !== defaultBranch) { + try { + const { stdout: countOut } = await execFileAsync( + GIT, + ['rev-list', '--count', `origin/${defaultBranch}..HEAD`], + { cwd: taskPath } + ); + aheadOfDefault = parseInt(countOut.trim(), 10) || 0; + } catch { + // origin/ may not exist + } } - } - return { success: true, branch, defaultBranch, ahead, behind, aheadOfDefault }; - } catch (error) { - log.error(`getBranchStatus: unexpected error for ${taskPath}:`, error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return { success: true, branch, defaultBranch, ahead, behind, aheadOfDefault }; + } catch (error) { + log.error(`getBranchStatus: unexpected error for ${taskPath}:`, error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } } - }); + ); ipcMain.handle( 'git:list-remote-branches', @@ -1998,13 +2533,13 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, ); // Git: Merge current branch to main via GitHub (create PR + merge immediately) - ipcMain.handle('git:merge-to-main', async (_, args: { taskPath: string }) => { - const { taskPath } = args || ({} as { taskPath: string }); + ipcMain.handle('git:merge-to-main', async (_, args: { taskPath: string; taskId?: string }) => { + const { taskPath, taskId } = args || ({} as { taskPath: string; taskId?: string }); try { - const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); - if (remoteProject) { - return await mergeToMainRemote(remoteProject.sshConnectionId, taskPath); + const remote = await resolveRemoteContext(taskPath, taskId); + if (remote) { + return await mergeToMainRemote(remote.connectionId, remote.remotePath); } // Get current and default branch names @@ -2015,7 +2550,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, let defaultBranch = 'main'; try { - const { stdout } = await execAsync( + const { stdout } = await execGhAsync( 'gh repo view --json defaultBranchRef -q .defaultBranchRef.name', { cwd: taskPath } ); @@ -2061,21 +2596,24 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } // Create PR (or use existing) + const autoCloseLinkedIssuesOnPrCreate = shouldAutoCloseLinkedIssuesOnPrCreate(); let prUrl = ''; let prExists = false; let taskMetadata: unknown = undefined; - try { - const task = await databaseService.getTaskByPath(taskPath); - taskMetadata = task?.metadata; - } catch (metadataError) { - log.debug('Unable to load task metadata for merge-to-main issue footer', { - taskPath, - metadataError, - }); + if (autoCloseLinkedIssuesOnPrCreate) { + try { + const task = await databaseService.getTaskByPath(taskPath); + taskMetadata = task?.metadata; + } catch (metadataError) { + log.debug('Unable to load task metadata for merge-to-main issue footer', { + taskPath, + metadataError, + }); + } } try { const prCreateArgs = ['pr', 'create', '--fill', '--base', defaultBranch]; - const { stdout: prOut } = await execFileAsync('gh', prCreateArgs, { cwd: taskPath }); + const { stdout: prOut } = await execGhFileAsync(prCreateArgs, { cwd: taskPath }); const urlMatch = prOut?.match(/https?:\/\/\S+/); prUrl = urlMatch ? urlMatch[0] : ''; prExists = true; @@ -2088,12 +2626,13 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, prExists = true; } - if (prExists) { + if (autoCloseLinkedIssuesOnPrCreate && prExists) { try { await patchCurrentPrBodyWithIssueFooter({ taskPath, metadata: taskMetadata, - execFile: execFileAsync, + execFile: (file, args, options) => + file === 'gh' ? execGhFileAsync(args, options) : execFileAsync(file, args, options), prUrl, }); } catch (editError) { @@ -2106,7 +2645,7 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, // Merge PR (branch cleanup happens when workspace is deleted) try { - await execAsync('gh pr merge --merge', { cwd: taskPath }); + await execGhAsync('gh pr merge --merge', { cwd: taskPath }); return { success: true, prUrl }; } catch (e) { const errMsg = (e as { stderr?: string })?.stderr || String(e); @@ -2204,17 +2743,29 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, } ); - ipcMain.handle('git:commit', async (_, args: { taskPath: string; message: string }) => { - try { - const pathErr = validateTaskPath(args.taskPath); - if (pathErr) return { success: false, error: pathErr }; - const result = await gitCommit(args.taskPath, args.message); - broadcastGitStatusChange(args.taskPath); - return { success: true, hash: result.hash }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + ipcMain.handle( + 'git:commit', + async (_, args: { taskPath: string; message: string; noVerify?: boolean }) => { + try { + const pathErr = validateTaskPath(args.taskPath); + if (pathErr) return { success: false, error: pathErr }; + const result = await gitCommit(args.taskPath, args.message, { noVerify: args.noVerify }); + broadcastGitStatusChange(args.taskPath); + return { success: true, hash: result.hash }; + } catch (error) { + const errObj = error as { stderr?: string; stdout?: string; message?: string }; + // Hooks (husky/lint-staged etc.) often write failure details to stdout, not just stderr. + // Combine both so the renderer can show the full output. + const stderr = errObj?.stderr?.trim() ?? ''; + const stdout = errObj?.stdout?.trim() ?? ''; + const combined = [stdout, stderr].filter(Boolean).join('\n').trim(); + return { + success: false, + error: combined || errObj?.message || String(error), + }; + } } - }); + ); ipcMain.handle('git:push', async (_, args: { taskPath: string }) => { try { @@ -2223,8 +2774,12 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, const result = await gitPush(args.taskPath); return { success: true, output: result.output }; } catch (error) { - const errObj = error as { stderr?: string; message?: string }; - return { success: false, error: errObj?.stderr?.trim() || errObj?.message || String(error) }; + const errObj = error as { stderr?: string; stdout?: string; message?: string }; + // Hooks (husky/lint-staged etc.) often write failure details to stdout, not just stderr. + const stderr = errObj?.stderr?.trim() ?? ''; + const stdout = errObj?.stdout?.trim() ?? ''; + const combined = [stdout, stderr].filter(Boolean).join('\n').trim(); + return { success: false, error: combined || errObj?.message || String(error) }; } }); @@ -2235,8 +2790,12 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, const result = await gitPull(args.taskPath); return { success: true, output: result.output }; } catch (error) { - const errObj = error as { stderr?: string; message?: string }; - return { success: false, error: errObj?.stderr?.trim() || errObj?.message || String(error) }; + const errObj = error as { stderr?: string; stdout?: string; message?: string }; + // Hooks (husky/lint-staged etc.) often write failure details to stdout, not just stderr. + const stderr = errObj?.stderr?.trim() ?? ''; + const stdout = errObj?.stdout?.trim() ?? ''; + const combined = [stdout, stderr].filter(Boolean).join('\n').trim(); + return { success: false, error: combined || errObj?.message || String(error) }; } }); @@ -2287,7 +2846,10 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, ipcMain.handle( 'git:get-commit-file-diff', - async (_, args: { taskPath: string; commitHash: string; filePath: string }) => { + async ( + _, + args: { taskPath: string; commitHash: string; filePath: string; forceLarge?: boolean } + ) => { try { const pathErr = validateTaskPath(args.taskPath); if (pathErr) return { success: false, error: pathErr }; @@ -2295,7 +2857,12 @@ current branch '${currentBranch}' ahead of base '${baseRef}'.`, return { success: false, error: 'Invalid commit hash' }; } // filePath is validated by path.resolve check in GitService.getCommitFileDiff - const diff = await gitGetCommitFileDiff(args.taskPath, args.commitHash, args.filePath); + const diff = await gitGetCommitFileDiff( + args.taskPath, + args.commitHash, + args.filePath, + args.forceLarge + ); return { success: true, diff }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; diff --git a/src/main/ipc/githubIpc.ts b/src/main/ipc/githubIpc.ts index dd91494a2d..c55cf3cfc9 100644 --- a/src/main/ipc/githubIpc.ts +++ b/src/main/ipc/githubIpc.ts @@ -1,17 +1,19 @@ import { ipcMain, app } from 'electron'; import { log } from '../lib/logger'; -import { GitHubService } from '../services/GitHubService'; +import { githubService } from '../services/GitHubService'; import { worktreeService } from '../services/WorktreeService'; import { githubCLIInstaller } from '../services/GitHubCLIInstaller'; +import { databaseService } from '../services/DatabaseService'; import { exec } from 'child_process'; import { promisify } from 'util'; import * as path from 'path'; import * as fs from 'fs'; +import * as crypto from 'crypto'; import { homedir } from 'os'; import { quoteShellArg } from '../utils/shellEscape'; +import { getAppSettings } from '../settings'; const execAsync = promisify(exec); -const githubService = new GitHubService(); const slugify = (name: string) => name @@ -20,32 +22,66 @@ const slugify = (name: string) => .replace(/-+/g, '-') .replace(/^-|-$/g, ''); +function parseGitHubRepository(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (!trimmed) return null; + + const match = trimmed.match( + /(?:git@github\.com:|https?:\/\/github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?$/ + ); + if (!match) return null; + + return `${match[1]}/${match[2]}`; +} + +async function getDefaultBranchForRepo(projectPath: string): Promise { + try { + const { stdout } = await execAsync( + 'git symbolic-ref --quiet --short refs/remotes/origin/HEAD', + { + cwd: projectPath, + } + ); + const ref = stdout.trim(); + if (ref.startsWith('origin/')) { + return ref.slice('origin/'.length) || 'main'; + } + } catch { + // Fall back to main when origin/HEAD is unavailable. + } + + return 'main'; +} + export function registerGithubIpc() { ipcMain.handle('github:connect', async (_, projectPath: string) => { try { - // Check if GitHub CLI is authenticated const isAuth = await githubService.isAuthenticated(); if (!isAuth) { - return { success: false, error: 'GitHub CLI not authenticated' }; + return { success: false, error: 'GitHub is not connected' }; } - // Get repository info from GitHub CLI try { - const { stdout } = await execAsync( - 'gh repo view --json name,nameWithOwner,defaultBranchRef', - { cwd: projectPath } - ); - const repoInfo = JSON.parse(stdout); + const { stdout } = await execAsync('git config --get remote.origin.url', { + cwd: projectPath, + }); + const repository = parseGitHubRepository(stdout); + if (!repository) { + return { + success: false, + error: 'Repository is not using a GitHub origin remote', + }; + } return { success: true, - repository: repoInfo.nameWithOwner, - branch: repoInfo.defaultBranchRef?.name || 'main', + repository, + branch: await getDefaultBranchForRepo(projectPath), }; } catch (error) { return { success: false, - error: 'Repository not found on GitHub or not connected to GitHub CLI', + error: 'Could not resolve the GitHub repository for this project', }; } } catch (error) { @@ -64,6 +100,16 @@ export function registerGithubIpc() { } }); + ipcMain.handle('github:auth:oauth', async () => { + try { + const result = await githubService.startOAuthAuth(); + return result; + } catch (error) { + log.error('OAuth auth failed:', error); + return { success: false, error: (error as Error).message }; + } + }); + // Cancel ongoing authentication ipcMain.handle('github:auth:cancel', async () => { try { @@ -84,27 +130,22 @@ export function registerGithubIpc() { } }); - // GitHub status: installed + authenticated + user + // GitHub status: optional gh install + Emdash auth + user ipcMain.handle('github:getStatus', async () => { try { - let installed = true; - try { - await execAsync('gh --version'); - } catch { - installed = false; - } + const installed = await githubCLIInstaller.isInstalled(); let authenticated = false; let user: any = null; - if (installed) { - try { - const { stdout } = await execAsync('gh api user'); - user = JSON.parse(stdout); - authenticated = true; - } catch { - authenticated = false; - user = null; + try { + const token = await githubService.getStoredToken(); + if (token) { + user = await githubService.getUserInfo(token); + authenticated = !!user; } + } catch { + authenticated = false; + user = null; } return { installed, authenticated, user }; @@ -116,7 +157,7 @@ export function registerGithubIpc() { ipcMain.handle('github:getUser', async () => { try { - const token = await (githubService as any)['getStoredToken'](); + const token = await githubService.getStoredToken(); if (!token) return null; return await githubService.getUserInfo(token); } catch (error) { @@ -127,7 +168,7 @@ export function registerGithubIpc() { ipcMain.handle('github:getRepositories', async () => { try { - const token = await (githubService as any)['getStoredToken'](); + const token = await githubService.getStoredToken(); if (!token) throw new Error('Not authenticated'); return await githubService.getRepositories(token); } catch (error) { @@ -238,22 +279,28 @@ export function registerGithubIpc() { } }); - ipcMain.handle('github:listPullRequests', async (_, args: { projectPath: string }) => { - const projectPath = args?.projectPath; - if (!projectPath) { - return { success: false, error: 'Project path is required' }; - } + ipcMain.handle( + 'github:listPullRequests', + async (_, args: { projectPath: string; limit?: number; searchQuery?: string }) => { + const projectPath = args?.projectPath; + if (!projectPath) { + return { success: false, error: 'Project path is required' }; + } - try { - const prs = await githubService.getPullRequests(projectPath); - return { success: true, prs }; - } catch (error) { - log.error('Failed to list pull requests:', error); - const message = - error instanceof Error ? error.message : 'Unable to list pull requests via GitHub CLI'; - return { success: false, error: message }; + try { + const result = await githubService.getPullRequests(projectPath, { + limit: args?.limit, + searchQuery: args?.searchQuery, + }); + return { success: true, prs: result.prs, totalCount: result.totalCount }; + } catch (error) { + log.error('Failed to list pull requests:', error); + const message = + error instanceof Error ? error.message : 'Unable to list pull requests via GitHub CLI'; + return { success: false, error: message }; + } } - }); + ); ipcMain.handle( 'github:createPullRequestWorktree', @@ -280,13 +327,49 @@ export function registerGithubIpc() { ? args.taskName.trim() : `pr-${prNumber}-${defaultSlug}`; const branchName = args.branchName || `pr/${prNumber}`; + const reviewProvider = getAppSettings().defaultProvider || 'claude'; + const buildTaskInfo = (taskPath: string, name: string) => ({ + id: crypto.randomUUID(), + projectId, + name, + branch: branchName, + path: taskPath, + status: 'active' as const, + agentId: reviewProvider, + useWorktree: true, + metadata: { + prNumber, + prTitle: args.prTitle || null, + }, + }); try { const currentWorktrees = await worktreeService.listWorktrees(projectPath); const existing = currentWorktrees.find((wt) => wt.branch === branchName); if (existing) { - return { success: true, worktree: existing, branchName, taskName: existing.name }; + const persistedTask = await databaseService.getTaskByPath(existing.path); + let existingTask = persistedTask ?? buildTaskInfo(existing.path, existing.name); + + if (persistedTask && !persistedTask.agentId) { + existingTask = { ...persistedTask, agentId: reviewProvider }; + } + + if (!persistedTask || !persistedTask.agentId) { + try { + await databaseService.saveTask(existingTask); + } catch (dbError) { + log.warn('Failed to save existing PR review task to database:', dbError); + } + } + + return { + success: true, + worktree: existing, + branchName, + taskName: existingTask.name, + task: existingTask, + }; } await githubService.ensurePullRequestBranch(projectPath, prNumber, branchName); @@ -307,7 +390,16 @@ export function registerGithubIpc() { { worktreePath } ); - return { success: true, worktree, branchName, taskName }; + // Save a task with PR metadata so the UI can identify it as a PR review task + const taskInfo = buildTaskInfo(worktree.path, taskName); + + try { + await databaseService.saveTask(taskInfo); + } catch (dbError) { + log.warn('Failed to save PR review task to database:', dbError); + } + + return { success: true, worktree, branchName, taskName, task: taskInfo }; } catch (error) { log.error('Failed to create PR worktree:', error); const message = @@ -317,6 +409,92 @@ export function registerGithubIpc() { } ); + ipcMain.handle( + 'github:getPullRequestBaseDiff', + async ( + _, + args: { + worktreePath: string; + prNumber: number; + } + ) => { + const { worktreePath, prNumber } = args || ({} as typeof args); + + if (!worktreePath || !prNumber) { + return { success: false, error: 'Missing required parameters' }; + } + + try { + // Find the project root from the worktree path + let projectRoot: string; + try { + const { stdout } = await execAsync('git rev-parse --show-toplevel', { + cwd: worktreePath, + }); + projectRoot = stdout.trim(); + } catch { + projectRoot = worktreePath; + } + + // Get PR details (base/head branches) + const prDetails = await githubService.getPullRequestDetails(projectRoot, prNumber); + if (!prDetails) { + return { success: false, error: 'Could not fetch PR details' }; + } + + const { baseRefName, headRefName } = prDetails; + + // Fetch the base branch to ensure we have the latest + try { + await execAsync(`git fetch origin ${quoteShellArg(baseRefName)}`, { cwd: worktreePath }); + } catch { + // Best effort — base ref may already be available locally + } + + // Use HEAD as the PR head (the worktree is checked out to the PR branch). + // This works for both same-repo and fork PRs, since origin/headRefName + // doesn't exist for fork PRs. + let diff: string; + try { + // Three-dot diff: changes introduced by the PR relative to the merge base + const { stdout } = await execAsync( + `git diff ${quoteShellArg(`origin/${baseRefName}`)}...HEAD`, + { cwd: worktreePath, maxBuffer: 10 * 1024 * 1024 } + ); + diff = stdout; + } catch { + // Fallback: two-dot diff + try { + const { stdout } = await execAsync( + `git diff ${quoteShellArg(`origin/${baseRefName}`)} HEAD`, + { cwd: worktreePath, maxBuffer: 10 * 1024 * 1024 } + ); + diff = stdout; + } catch (diffError) { + return { + success: false, + error: diffError instanceof Error ? diffError.message : 'Failed to compute PR diff', + }; + } + } + + return { + success: true, + diff, + baseBranch: baseRefName, + headBranch: headRefName, + prUrl: prDetails.url, + }; + } catch (error) { + log.error('Failed to get PR base diff:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get PR diff', + }; + } + } + ); + ipcMain.handle('github:checkCLIInstalled', async () => { try { return await githubCLIInstaller.isInstalled(); @@ -463,8 +641,10 @@ export function registerGithubIpc() { try { // Security: Use quoteShellArg to prevent command injection const repoRef = `${quoteShellArg(owner)}/${quoteShellArg(name)}`; + const env = await githubService.getCliEnvironment(); await execAsync(`gh repo delete ${repoRef} --yes`, { timeout: 10000, + env, }); } catch (cleanupError) { log.warn('Failed to cleanup GitHub repo after clone failure:', cleanupError); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 6eabfdcb64..2ef8028a7b 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -19,16 +19,26 @@ import { appSettingsController } from './settingsIpc'; import { registerHostPreviewIpc } from './hostPreviewIpc'; import { registerBrowserIpc } from './browserIpc'; import { registerNetIpc } from './netIpc'; -import { registerLineCommentsIpc } from './lineCommentsIpc'; import { registerSshIpc } from './sshIpc'; import { registerSkillsIpc } from './skillsIpc'; +import { registerWorkspaceIpc } from './workspaceIpc'; +import { registerMcpIpc } from './mcpIpc'; import { createRPCRouter, registerRPCRouter } from '../../shared/ipc/rpc'; import { ipcMain } from 'electron'; import { registerGitlabIpc } from './gitlabIpc'; +import { registerPlainIpc } from './plainIpc'; +import { registerSentryIpc } from './sentryIpc'; +import { registerForgejoIpc } from './forgejoIpc'; +import { registerAccountIpc } from './accountIpc'; +import { changelogController } from './changelogIpc'; +import { registerAutomationsIpc } from './automationsIpc'; +import { registerIntegrationsIpc } from './integrationsIpc'; +import { registerPerformanceIpc } from './performanceIpc'; export const rpcRouter = createRPCRouter({ db: databaseController, appSettings: appSettingsController, + changelog: changelogController, }); export type RpcRouter = typeof rpcRouter; @@ -47,12 +57,11 @@ export function registerAllIpc() { registerProjectIpc(); registerProjectSettingsIpc(); registerGithubIpc(); + registerAccountIpc(); registerGitIpc(); registerHostPreviewIpc(); registerBrowserIpc(); registerNetIpc(); - registerLineCommentsIpc(); - // Existing modules registerPtyIpc(); registerWorktreeIpc(); @@ -64,5 +73,13 @@ export function registerAllIpc() { registerPlanLockIpc(); registerSshIpc(); registerSkillsIpc(); + registerWorkspaceIpc(); + registerMcpIpc(); registerGitlabIpc(); + registerPlainIpc(); + registerSentryIpc(); + registerForgejoIpc(); + registerAutomationsIpc(); + registerIntegrationsIpc(); + registerPerformanceIpc(); } diff --git a/src/main/ipc/integrationsIpc.ts b/src/main/ipc/integrationsIpc.ts new file mode 100644 index 0000000000..283f777698 --- /dev/null +++ b/src/main/ipc/integrationsIpc.ts @@ -0,0 +1,101 @@ +import { ipcMain } from 'electron'; +import { log } from '../lib/logger'; +import type { IntegrationId, IntegrationStatusMap } from '../../shared/integrations/types'; + +async function checkGitHub(): Promise { + try { + const { githubService } = await import('../services/GitHubService'); + return await githubService.isAuthenticated(); + } catch { + return false; + } +} + +async function checkLinear(): Promise { + try { + const { default: LinearService } = await import('../services/LinearService'); + const linear = new LinearService(); + return !!(await linear.checkConnection()).connected; + } catch { + return false; + } +} + +async function checkJira(): Promise { + try { + const { default: JiraService } = await import('../services/JiraService'); + const jira = new JiraService(); + return !!(await jira.checkConnection()).connected; + } catch { + return false; + } +} + +async function checkGitLab(): Promise { + try { + const { GitLabService } = await import('../services/GitLabService'); + const gitlab = new GitLabService(); + return !!(await gitlab.checkConnection()).success; + } catch { + return false; + } +} + +async function checkPlain(): Promise { + try { + const { default: PlainService } = await import('../services/PlainService'); + const plain = new PlainService(); + return !!(await plain.checkConnection()).connected; + } catch { + return false; + } +} + +async function checkForgejo(): Promise { + try { + const { ForgejoService } = await import('../services/ForgejoService'); + const forgejo = new ForgejoService(); + return !!(await forgejo.checkConnection()).success; + } catch { + return false; + } +} + +async function checkSentry(): Promise { + try { + const { sentryService } = await import('../services/SentryService'); + return !!(await sentryService.checkConnection()).connected; + } catch { + return false; + } +} + +const checkers: Record Promise> = { + github: checkGitHub, + linear: checkLinear, + jira: checkJira, + gitlab: checkGitLab, + plain: checkPlain, + forgejo: checkForgejo, + sentry: checkSentry, +}; + +export function registerIntegrationsIpc(): void { + ipcMain.handle('integrations:statusMap', async () => { + try { + const ids = Object.keys(checkers) as IntegrationId[]; + const results = await Promise.all(ids.map((id) => checkers[id]())); + const data = {} as IntegrationStatusMap; + ids.forEach((id, i) => { + data[id] = results[i]; + }); + return { success: true, data }; + } catch (error) { + log.error('Failed to get integration status map:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); +} diff --git a/src/main/ipc/lineCommentsIpc.ts b/src/main/ipc/lineCommentsIpc.ts deleted file mode 100644 index c8b4951bf6..0000000000 --- a/src/main/ipc/lineCommentsIpc.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ipcMain } from 'electron'; -import { log } from '../lib/logger'; -import { databaseService } from '../services/DatabaseService'; -import { formatCommentsForAgent } from '../../shared/lineComments'; - -export function registerLineCommentsIpc() { - ipcMain.handle('lineComments:create', async (_, input) => { - try { - const id = await databaseService.saveLineComment(input); - return { success: true, id }; - } catch (error) { - log.error('Failed to create line comment:', error); - return { success: false, error: (error as Error).message }; - } - }); - - ipcMain.handle('lineComments:get', async (_, args: { taskId: string; filePath?: string }) => { - try { - const comments = await databaseService.getLineComments(args.taskId, args.filePath); - return { success: true, comments }; - } catch (error) { - log.error('Failed to get line comments:', error); - return { success: false, error: (error as Error).message }; - } - }); - - ipcMain.handle('lineComments:update', async (_, input: { id: string; content: string }) => { - try { - await databaseService.updateLineComment(input.id, input.content); - return { success: true }; - } catch (error) { - log.error('Failed to update line comment:', error); - return { success: false, error: (error as Error).message }; - } - }); - - ipcMain.handle('lineComments:delete', async (_, id: string) => { - try { - await databaseService.deleteLineComment(id); - return { success: true }; - } catch (error) { - log.error('Failed to delete line comment:', error); - return { success: false, error: (error as Error).message }; - } - }); - - ipcMain.handle('lineComments:getFormatted', async (_, taskId: string) => { - try { - const comments = await databaseService.getLineComments(taskId); - const formatted = formatCommentsForAgent(comments); - return { success: true, formatted }; - } catch (error) { - log.error('Failed to format line comments:', error); - return { success: false, error: (error as Error).message }; - } - }); - - ipcMain.handle('lineComments:markSent', async (_, commentIds: string[]) => { - try { - await databaseService.markCommentsSent(commentIds); - return { success: true }; - } catch (error) { - log.error('Failed to mark comments as sent:', error); - return { success: false, error: (error as Error).message }; - } - }); - - ipcMain.handle('lineComments:getUnsent', async (_, taskId: string) => { - try { - const comments = await databaseService.getUnsentComments(taskId); - return { success: true, comments }; - } catch (error) { - log.error('Failed to get unsent comments:', error); - return { success: false, error: (error as Error).message }; - } - }); -} diff --git a/src/main/ipc/mcpIpc.ts b/src/main/ipc/mcpIpc.ts new file mode 100644 index 0000000000..820908f88f --- /dev/null +++ b/src/main/ipc/mcpIpc.ts @@ -0,0 +1,72 @@ +import { ipcMain } from 'electron'; +import { mcpService } from '../services/McpService'; +import { getAllMcpAgentIds, agentSupportsHttp } from '../services/mcp/configPaths'; +import { log } from '../lib/logger'; +import type { McpServer, McpProvidersResponse } from '@shared/mcp/types'; +import { PROVIDERS } from '@shared/providers/registry'; +import { providerStatusCache } from '../services/providerStatusCache'; +import { connectionsService } from '../services/ConnectionsService'; + +function mapProviders(agentIds: string[]): McpProvidersResponse[] { + const statuses = providerStatusCache.getAll(); + return agentIds.map((id) => { + const provider = PROVIDERS.find((p) => p.id === id); + return { + id, + name: provider?.name ?? id, + installed: statuses[id]?.installed ?? false, + supportsHttp: agentSupportsHttp(id), + }; + }); +} + +export function registerMcpIpc(): void { + ipcMain.handle('mcp:load-all', async () => { + try { + const data = await mcpService.loadAll(); + return { success: true, data }; + } catch (error) { + log.error('Failed to load MCP servers:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle('mcp:save-server', async (_event, server: McpServer) => { + try { + await mcpService.saveServer(server); + return { success: true }; + } catch (error) { + log.error('Failed to save MCP server:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle('mcp:remove-server', async (_event, serverName: string) => { + try { + await mcpService.removeServer(serverName); + return { success: true }; + } catch (error) { + log.error('Failed to remove MCP server:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle('mcp:get-providers', async () => { + try { + return { success: true, data: mapProviders(getAllMcpAgentIds()) }; + } catch (error) { + log.error('Failed to get MCP providers:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle('mcp:refresh-providers', async () => { + try { + await connectionsService.refreshAllProviderStatuses(); + return { success: true, data: mapProviders(getAllMcpAgentIds()) }; + } catch (error) { + log.error('Failed to refresh MCP providers:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); +} diff --git a/src/main/ipc/performanceIpc.ts b/src/main/ipc/performanceIpc.ts new file mode 100644 index 0000000000..26eddc0fc1 --- /dev/null +++ b/src/main/ipc/performanceIpc.ts @@ -0,0 +1,413 @@ +import os from 'node:os'; +import { BrowserWindow, app, ipcMain, webContents } from 'electron'; +import { inArray } from 'drizzle-orm'; +import { log } from '../lib/logger'; +import type { + AppMetrics, + HostMetrics, + ProjectMetrics, + ResourceMetricsSnapshot, + SessionMetrics, + TaskMetrics, +} from '../../shared/performanceTypes'; +import { parsePtyId } from '../../shared/ptyId'; +import { getProvider } from '../../shared/providers/registry'; +import { getActivePtyInfo } from '../services/ptyManager'; +import { databaseService } from '../services/DatabaseService'; +import { getDrizzleClient } from '../db/drizzleClient'; +import { conversations as conversationsTable } from '../db/schema'; +import { captureProcessSnapshot, getSubtreeResources } from '../lib/processTree'; + +// ── Helpers ────────────────────────────────────────────────────────── + +function fin(v: unknown): number { + if (typeof v !== 'number' || !isFinite(v)) return 0; + return Math.max(0, v); +} + +function createHostMetrics(): HostMetrics { + const totalMemory = fin(os.totalmem()); + const freeMemory = fin(os.freemem()); + const usedMemory = Math.max(0, totalMemory - freeMemory); + return { + totalMemory, + freeMemory, + usedMemory, + memoryUsagePercent: totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0, + cpuCoreCount: Math.max(1, os.cpus().length), + }; +} + +function collectAppMetrics(): AppMetrics { + const electronMetrics = app.getAppMetrics(); + const main = { cpu: 0, memory: 0 }; + const renderer = { cpu: 0, memory: 0 }; + const other = { cpu: 0, memory: 0 }; + + for (const proc of electronMetrics) { + const cpu = fin(proc.cpu?.percentCPUUsage); + // Electron returns workingSetSize in KB + const memory = fin(proc.memory?.workingSetSize) * 1024; + const type = proc.type.toLowerCase(); + if (type === 'browser') { + main.cpu += cpu; + main.memory += memory; + } else if (type === 'renderer' || type === 'tab') { + renderer.cpu += cpu; + renderer.memory += memory; + } else { + other.cpu += cpu; + other.memory += memory; + } + } + + return { + cpu: main.cpu + renderer.cpu + other.cpu, + memory: main.memory + renderer.memory + other.memory, + main, + renderer, + other, + }; +} + +// ── Snapshot caching ───────────────────────────────────────────────── + +const INTERACTIVE_MAX_AGE_MS = 1_000; +const IDLE_MAX_AGE_MS = 15_000; + +let cachedSnapshot: ResourceMetricsSnapshot | null = null; +let inflightCollection: Promise | null = null; + +function emptySnapshot(): ResourceMetricsSnapshot { + return { + app: { + cpu: 0, + memory: 0, + main: { cpu: 0, memory: 0 }, + renderer: { cpu: 0, memory: 0 }, + other: { cpu: 0, memory: 0 }, + }, + projects: [], + host: createHostMetrics(), + totalCpu: 0, + totalMemory: 0, + collectedAt: Date.now(), + }; +} + +async function collectNow(): Promise { + const appMetrics = collectAppMetrics(); + + // ── Per-PTY resource usage via process tree ──────────────────────── + const ptyInfos = getActivePtyInfo(); + const pidsToQuery = ptyInfos + .map((p) => p.pid) + .filter((pid): pid is number => pid !== null && pid > 0); + + const processSnapshot = pidsToQuery.length > 0 ? await captureProcessSnapshot() : null; + + // Map ptyId → resources + const ptyResources = new Map(); + for (const info of ptyInfos) { + if (info.pid && info.pid > 0 && processSnapshot) { + const res = getSubtreeResources(processSnapshot, info.pid); + ptyResources.set(info.ptyId, { cpu: fin(res.cpu), memory: fin(res.memory) }); + } else { + ptyResources.set(info.ptyId, { cpu: 0, memory: 0 }); + } + } + + // ── Resolve PTY → task → project via DB + ptyId parsing ─────────── + // Parse pty IDs to figure out which task they belong to + type PtyMeta = { + providerId: string; + providerName: string; + kind: 'main' | 'chat'; + taskId: string; + suffix: string; + }; + const ptyMeta = new Map(); + + for (const info of ptyInfos) { + const parsed = parsePtyId(info.ptyId); + if (!parsed) continue; + + // For 'main' PTYs, suffix = taskId. For 'chat' PTYs, suffix = conversationId. + // We need the taskId; for chat PTYs we'll try to resolve it later. + const provider = getProvider(parsed.providerId); + ptyMeta.set(info.ptyId, { + providerId: parsed.providerId, + providerName: provider?.name ?? parsed.providerId, + kind: parsed.kind, + taskId: parsed.kind === 'main' ? parsed.suffix : '', // chat PTYs need resolution + suffix: parsed.suffix, + }); + } + + // Hoist DB fetches (used by both chat resolution and hierarchy building) + let allTasks: Array<{ id: string; projectId: string; name: string }> = []; + let allProjects: Array<{ id: string; name: string }> = []; + try { + [allTasks, allProjects] = await Promise.all([ + databaseService.getTasks(), + databaseService.getProjects(), + ]); + } catch (err) { + log.warn('perf:collectNow - failed to fetch tasks/projects', { error: err }); + } + + // Resolve chat PTY taskIds via a single batch query on the conversations table + const chatPtysNeedingResolution = [...ptyMeta.entries()].filter( + ([, m]) => m.kind === 'chat' && !m.taskId + ); + if (chatPtysNeedingResolution.length > 0) { + try { + // Collect the conversation IDs we need to resolve + const convIdToPtyIds = new Map(); + for (const [ptyId] of chatPtysNeedingResolution) { + const meta = ptyMeta.get(ptyId); + if (meta?.suffix) { + const existing = convIdToPtyIds.get(meta.suffix) ?? []; + existing.push(ptyId); + convIdToPtyIds.set(meta.suffix, existing); + } + } + + if (convIdToPtyIds.size > 0) { + // Single query: SELECT id, task_id FROM conversations WHERE id IN (...) + const { db } = await getDrizzleClient(); + const rows = await db + .select({ id: conversationsTable.id, taskId: conversationsTable.taskId }) + .from(conversationsTable) + .where(inArray(conversationsTable.id, [...convIdToPtyIds.keys()])); + + for (const row of rows) { + const ptyIds = convIdToPtyIds.get(row.id); + if (ptyIds) { + for (const ptyId of ptyIds) { + const meta = ptyMeta.get(ptyId); + if (meta) meta.taskId = row.taskId; + } + } + } + } + } catch (err) { + log.warn('perf:collectNow - failed to resolve chat PTY task IDs', { error: err }); + } + } + + // ── Build project → task → session hierarchy ────────────────────── + + const taskMap = new Map(allTasks.map((t) => [t.id, t])); + const projectMap = new Map(allProjects.map((p) => [p.id, p])); + const ptyInfoById = new Map(ptyInfos.map((p) => [p.ptyId, p])); + + // Group by task, then by project + const taskSessions = new Map(); + for (const [ptyId, meta] of ptyMeta) { + if (!meta.taskId) continue; + const res = ptyResources.get(ptyId) ?? { cpu: 0, memory: 0 }; + const info = ptyInfoById.get(ptyId); + const session: SessionMetrics = { + ptyId, + providerId: meta.providerId, + providerName: meta.providerName, + kind: meta.kind, + pid: info?.pid ?? null, + cpu: res.cpu, + memory: res.memory, + }; + const existing = taskSessions.get(meta.taskId) ?? []; + existing.push(session); + taskSessions.set(meta.taskId, existing); + } + + // Group tasks by project + const projectTasks = new Map(); + for (const [taskId, sessions] of taskSessions) { + const task = taskMap.get(taskId); + const projectId = task?.projectId ?? 'unknown'; + + const taskCpu = sessions.reduce((s, sess) => s + sess.cpu, 0); + const taskMem = sessions.reduce((s, sess) => s + sess.memory, 0); + + const taskMetrics: TaskMetrics = { + taskId, + taskName: task?.name ?? 'Unknown Task', + cpu: taskCpu, + memory: taskMem, + sessions, + }; + + const existing = projectTasks.get(projectId) ?? []; + existing.push(taskMetrics); + projectTasks.set(projectId, existing); + } + + const projects: ProjectMetrics[] = []; + for (const [projectId, tasks] of projectTasks) { + const project = projectMap.get(projectId); + const projCpu = tasks.reduce((s, t) => s + t.cpu, 0); + const projMem = tasks.reduce((s, t) => s + t.memory, 0); + projects.push({ + projectId, + projectName: project?.name ?? 'Unknown Project', + cpu: projCpu, + memory: projMem, + tasks, + }); + } + + const sessionCpuTotal = projects.reduce((s, p) => s + p.cpu, 0); + const sessionMemTotal = projects.reduce((s, p) => s + p.memory, 0); + + return { + app: appMetrics, + projects, + host: createHostMetrics(), + totalCpu: appMetrics.cpu + sessionCpuTotal, + totalMemory: appMetrics.memory + sessionMemTotal, + collectedAt: Date.now(), + }; +} + +async function getSnapshot( + mode: 'interactive' | 'idle' = 'interactive', + force = false +): Promise { + const maxAge = mode === 'interactive' ? INTERACTIVE_MAX_AGE_MS : IDLE_MAX_AGE_MS; + + if (!force && cachedSnapshot) { + const age = Date.now() - cachedSnapshot.collectedAt; + if (age <= maxAge) return cachedSnapshot; + } + + if (inflightCollection) return inflightCollection; + + inflightCollection = (async () => { + try { + const snapshot = await collectNow(); + cachedSnapshot = snapshot; + return snapshot; + } catch (err) { + log.warn('perf:getSnapshot - failed to collect metrics', { error: err }); + const fallback = cachedSnapshot ?? emptySnapshot(); + cachedSnapshot = fallback; + return fallback; + } finally { + inflightCollection = null; + } + })(); + + return inflightCollection; +} + +// ── Polling for active subscribers ─────────────────────────────────── + +let intervalId: ReturnType | null = null; + +/** Track subscriber count per webContents id — prevents leaked polling on renderer crash. */ +const subscribersByWebContents = new Map(); +const cleanedUpWebContents = new Set(); + +function totalSubscribers(): number { + let total = 0; + for (const count of subscribersByWebContents.values()) total += count; + return total; +} + +function cleanupWebContents(webContentsId: number) { + if (cleanedUpWebContents.has(webContentsId)) return; + cleanedUpWebContents.add(webContentsId); + subscribersByWebContents.delete(webContentsId); + if (totalSubscribers() === 0) stopPolling(); +} + +function broadcastSnapshot(snapshot: ResourceMetricsSnapshot) { + // Only send to webContents that called perf:subscribe (not every window). + // Snapshot the keys to avoid mutation during iteration. + for (const wcId of [...subscribersByWebContents.keys()]) { + const wc = webContents.fromId(wcId); + if (wc && !wc.isDestroyed()) { + wc.send('perf:snapshot', snapshot); + } else { + // webContents is gone — clean up the stale subscription entry + cleanupWebContents(wcId); + } + } +} + +const POLL_INTERVAL_MS = 1_000; + +let lastBroadcastCpu = -1; +let lastBroadcastMem = -1; +let lastBroadcastProjectCount = -1; + +function startPolling() { + if (intervalId) return; + intervalId = setInterval(async () => { + const snapshot = await getSnapshot('interactive', true); + // Skip broadcast when nothing meaningfully changed + if ( + snapshot.totalCpu === lastBroadcastCpu && + snapshot.totalMemory === lastBroadcastMem && + snapshot.projects.length === lastBroadcastProjectCount + ) { + return; + } + lastBroadcastCpu = snapshot.totalCpu; + lastBroadcastMem = snapshot.totalMemory; + lastBroadcastProjectCount = snapshot.projects.length; + broadcastSnapshot(snapshot); + }, POLL_INTERVAL_MS); +} + +function stopPolling() { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } +} + +// ── IPC registration ───────────────────────────────────────────────── + +export function registerPerformanceIpc() { + ipcMain.handle('perf:subscribe', async (event) => { + const wcId = event.sender.id; + subscribersByWebContents.set(wcId, (subscribersByWebContents.get(wcId) ?? 0) + 1); + cleanedUpWebContents.delete(wcId); + + // Automatically clean up if this webContents is destroyed (crash, close without unsubscribe) + if (!event.sender.isDestroyed()) { + event.sender.once('destroyed', () => cleanupWebContents(wcId)); + } + + startPolling(); + const snapshot = await getSnapshot('interactive'); + return { success: true, data: snapshot }; + }); + + ipcMain.handle('perf:unsubscribe', (event) => { + const wcId = event.sender.id; + const current = subscribersByWebContents.get(wcId) ?? 0; + if (current <= 1) { + subscribersByWebContents.delete(wcId); + } else { + subscribersByWebContents.set(wcId, current - 1); + } + if (totalSubscribers() === 0) stopPolling(); + return { success: true }; + }); + + ipcMain.handle('perf:getSnapshot', async (_event, mode?: string) => { + try { + const snapshot = await getSnapshot(mode === 'idle' ? 'idle' : 'interactive'); + return { success: true, data: snapshot }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }); +} diff --git a/src/main/ipc/plainIpc.ts b/src/main/ipc/plainIpc.ts new file mode 100644 index 0000000000..ce32dd6356 --- /dev/null +++ b/src/main/ipc/plainIpc.ts @@ -0,0 +1,56 @@ +import { ipcMain } from 'electron'; +import PlainService from '../services/PlainService'; + +const plainService = new PlainService(); + +export function registerPlainIpc() { + ipcMain.handle('plain:saveToken', async (_event, token: string) => { + if (!token || typeof token !== 'string') { + return { success: false, error: 'A Plain API token is required.' }; + } + + return plainService.saveToken(token); + }); + + ipcMain.handle('plain:checkConnection', async () => { + return plainService.checkConnection(); + }); + + ipcMain.handle('plain:clearToken', async () => { + return plainService.clearToken(); + }); + + ipcMain.handle('plain:initialFetch', async (_event, limit?: number, statuses?: string[]) => { + try { + const sanitizedStatuses = Array.isArray(statuses) + ? statuses.filter((s) => ['TODO', 'DONE', 'SNOOZED'].includes(s)) + : undefined; + const threads = await plainService.initialFetch( + typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined, + sanitizedStatuses && sanitizedStatuses.length > 0 ? sanitizedStatuses : undefined + ); + return { success: true, threads }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unable to fetch Plain threads right now.'; + return { success: false, error: message }; + } + }); + + ipcMain.handle('plain:searchThreads', async (_event, searchTerm: string, limit?: number) => { + if (!searchTerm || typeof searchTerm !== 'string') { + return { success: false, error: 'Search term is required.' }; + } + + try { + const threads = await plainService.searchThreads(searchTerm, limit ?? 20); + return { success: true, threads }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unable to search Plain threads right now.'; + return { success: false, error: message }; + } + }); +} + +export default registerPlainIpc; diff --git a/src/main/ipc/projectIpc.ts b/src/main/ipc/projectIpc.ts index 9d6a25fd26..e18c1ba4f3 100644 --- a/src/main/ipc/projectIpc.ts +++ b/src/main/ipc/projectIpc.ts @@ -51,7 +51,10 @@ const detectDefaultBranch = async (projectPath: string, remote?: string | null) cwd: projectPath, }); const match = stdout.match(/HEAD branch:\s*(\S+)/); - return match ? match[1] : null; + if (match && match[1] !== '(unknown)') { + return match[1]; + } + return null; } catch { return null; } diff --git a/src/main/ipc/sentryIpc.ts b/src/main/ipc/sentryIpc.ts new file mode 100644 index 0000000000..70c01442e3 --- /dev/null +++ b/src/main/ipc/sentryIpc.ts @@ -0,0 +1,50 @@ +import { ipcMain } from 'electron'; +import { sentryService } from '../services/SentryService'; + +export function registerSentryIpc() { + ipcMain.handle('sentry:saveToken', async (_event, token: string, organizationSlug?: string) => { + if (!token || typeof token !== 'string') { + return { success: false, error: 'A Sentry auth token is required.' }; + } + + return sentryService.saveToken(token, organizationSlug); + }); + + ipcMain.handle('sentry:checkConnection', async () => { + return sentryService.checkConnection(); + }); + + ipcMain.handle('sentry:clearToken', async () => { + return sentryService.clearToken(); + }); + + ipcMain.handle('sentry:initialFetch', async (_event, limit?: number) => { + try { + const issues = await sentryService.initialFetch( + typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined + ); + return { success: true, issues }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unable to fetch Sentry issues right now.'; + return { success: false, error: message }; + } + }); + + ipcMain.handle('sentry:searchIssues', async (_event, searchTerm: string, limit?: number) => { + if (!searchTerm || typeof searchTerm !== 'string') { + return { success: false, error: 'Search term is required.' }; + } + + try { + const issues = await sentryService.searchIssues(searchTerm, limit ?? 25); + return { success: true, issues }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unable to search Sentry issues right now.'; + return { success: false, error: message }; + } + }); +} + +export default registerSentryIpc; diff --git a/src/main/ipc/skillsIpc.ts b/src/main/ipc/skillsIpc.ts index a5cb63c69b..7f3edaea29 100644 --- a/src/main/ipc/skillsIpc.ts +++ b/src/main/ipc/skillsIpc.ts @@ -23,15 +23,18 @@ export function registerSkillsIpc(): void { } }); - ipcMain.handle('skills:install', async (_, args: { skillId: string }) => { - try { - const skill = await skillsService.installSkill(args.skillId); - return { success: true, data: skill }; - } catch (error) { - log.error('Failed to install skill:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + ipcMain.handle( + 'skills:install', + async (_, args: { skillId: string; source?: { owner: string; repo: string } }) => { + try { + const skill = await skillsService.installSkill(args.skillId, args.source); + return { success: true, data: skill }; + } catch (error) { + log.error('Failed to install skill:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } } - }); + ); ipcMain.handle('skills:uninstall', async (_, args: { skillId: string }) => { try { @@ -43,15 +46,18 @@ export function registerSkillsIpc(): void { } }); - ipcMain.handle('skills:getDetail', async (_, args: { skillId: string }) => { - try { - const skill = await skillsService.getSkillDetail(args.skillId); - return { success: true, data: skill }; - } catch (error) { - log.error('Failed to get skill detail:', error); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + ipcMain.handle( + 'skills:getDetail', + async (_, args: { skillId: string; source?: { owner: string; repo: string } }) => { + try { + const skill = await skillsService.getSkillDetail(args.skillId, args.source); + return { success: true, data: skill }; + } catch (error) { + log.error('Failed to get skill detail:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } } - }); + ); ipcMain.handle('skills:getDetectedAgents', async () => { try { @@ -63,6 +69,16 @@ export function registerSkillsIpc(): void { } }); + ipcMain.handle('skills:search', async (_, args: { query: string }) => { + try { + const skills = await skillsService.searchSkillsSh(args.query); + return { success: true, data: skills }; + } catch (error) { + log.error('Failed to search skills.sh:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle( 'skills:create', async (_, args: { name: string; description: string; content?: string }) => { diff --git a/src/main/ipc/sshIpc.ts b/src/main/ipc/sshIpc.ts index 1cd643e954..0c85707dd1 100644 --- a/src/main/ipc/sshIpc.ts +++ b/src/main/ipc/sshIpc.ts @@ -9,7 +9,12 @@ import { sshConnections as sshConnectionsTable, type SshConnectionInsert } from import { eq, desc } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { quoteShellArg } from '../utils/shellEscape'; -import { parseSshConfigFile, resolveIdentityAgent } from '../utils/sshConfigParser'; +import { getSshExecuteCommandValidationError } from '../utils/sshCommandValidation'; +import { + parseSshConfigFile, + resolveIdentityAgent, + resolveProxyCommand, +} from '../utils/sshConfigParser'; import type { SshConfig, ConnectionTestResult, @@ -171,10 +176,20 @@ export function registerSshIpc() { testClient.on('ready', () => { const latency = Date.now() - startTime; testClient.end(); + try { + proxyProc?.kill(); + } catch { + /* ignore */ + } resolve({ success: true, latency, debugLogs }); }); testClient.on('error', (err: Error) => { + try { + proxyProc?.kill(); + } catch { + /* ignore */ + } resolve({ success: false, error: err.message, debugLogs }); }); @@ -235,6 +250,44 @@ export function registerSshIpc() { } else if (config.authType === 'agent') { const identityAgent = await resolveIdentityAgent(config.host); connectConfig.agent = identityAgent || process.env.SSH_AUTH_SOCK; + debugLogs.push( + `[emdash] authType=agent, socket=${connectConfig.agent ?? '(not found)'}` + ); + } + + debugLogs.push( + `[emdash] authType=${config.authType}, host=${config.host}, port=${config.port}, username=${config.username}` + ); + + // Check for ProxyCommand in ~/.ssh/config + const proxyCommand = await resolveProxyCommand(config.host, config.port); + debugLogs.push(`[emdash] ProxyCommand resolve: ${proxyCommand ?? '(none)'}`); + let proxyProc: import('child_process').ChildProcess | undefined; + if (proxyCommand) { + const { Duplex } = await import('stream'); + const { spawn } = await import('child_process'); + proxyProc = spawn('sh', ['-c', proxyCommand], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + const sock = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + return proxyProc!.stdin!.write(chunk, encoding, callback); + }, + final(callback) { + proxyProc!.stdin!.end(callback); + }, + }); + proxyProc.stdout!.on('data', (data) => sock.push(data)); + proxyProc.stdout!.on('close', () => sock.push(null)); + proxyProc.stderr!.on('data', (data) => { + debugLogs.push(`[emdash] proxy stderr: ${data.toString()}`); + }); + proxyProc.on('error', (err) => { + debugLogs.push(`[emdash] proxy error: ${err.message}`); + sock.destroy(err); + }); + (connectConfig as any).sock = sock; } testClient.connect(connectConfig); @@ -506,23 +559,6 @@ export function registerSshIpc() { } ); - // Execute command (guarded: only allow known-safe command prefixes from renderer) - const ALLOWED_COMMAND_PREFIXES = [ - 'git ', - 'ls ', - 'pwd', - 'cat ', - 'head ', - 'tail ', - 'wc ', - 'stat ', - 'file ', - 'which ', - 'echo ', - 'test ', - '[ ', - ]; - ipcMain.handle( SSH_IPC_CHANNELS.EXECUTE_COMMAND, async ( @@ -538,14 +574,11 @@ export function registerSshIpc() { error?: string; }> => { try { - // Validate the command against the allowlist const trimmed = command.trimStart(); - const isAllowed = ALLOWED_COMMAND_PREFIXES.some( - (prefix) => trimmed === prefix.trimEnd() || trimmed.startsWith(prefix) - ); - if (!isAllowed) { + const validationError = getSshExecuteCommandValidationError(command); + if (validationError) { console.warn(`[sshIpc] Blocked disallowed command: ${trimmed.slice(0, 80)}`); - return { success: false, error: 'Command not allowed' }; + return { success: false, error: validationError }; } const result = await sshService.executeCommand(connectionId, command, cwd); diff --git a/src/main/ipc/telemetryIpc.ts b/src/main/ipc/telemetryIpc.ts index 89e046aca9..5ed72fc47a 100644 --- a/src/main/ipc/telemetryIpc.ts +++ b/src/main/ipc/telemetryIpc.ts @@ -88,8 +88,16 @@ const RENDERER_ALLOWED_EVENTS = new Set([ 'gitlab_disconnected', 'gitlab_issues_searched', 'gitlab_issue_selected', + // Forgejo integration + 'forgejo_connected', + 'forgejo_disconnected', + 'forgejo_issues_searched', + 'forgejo_issue_selected', // Task with issue 'task_created_with_issue', + // Workspace provider + 'workspace_provisioning_task_created', + 'workspace_provider_config_saved', // Settings & Preferences 'settings_tab_viewed', 'theme_changed', diff --git a/src/main/ipc/workspaceIpc.ts b/src/main/ipc/workspaceIpc.ts new file mode 100644 index 0000000000..281c39f04e --- /dev/null +++ b/src/main/ipc/workspaceIpc.ts @@ -0,0 +1,139 @@ +import { ipcMain, BrowserWindow } from 'electron'; +import { log } from '../lib/logger'; +import { + workspaceProviderService, + type ProvisionConfig, +} from '../services/WorkspaceProviderService'; + +const WORKSPACE_CHANNELS = { + PROVISION: 'workspace:provision', + CANCEL: 'workspace:cancel', + TERMINATE: 'workspace:terminate', + STATUS: 'workspace:status', + PROVISION_PROGRESS: 'workspace:provision-progress', + PROVISION_TIMEOUT_WARNING: 'workspace:provision-timeout-warning', + PROVISION_COMPLETE: 'workspace:provision-complete', +} as const; + +/** + * Registers IPC handlers for workspace provisioning. + * + * The provision flow is event-based: + * - `workspace:provision` returns immediately with { success, instanceId } + * - Progress events are pushed to the renderer via `workspace:provision-progress` + * - Completion is signalled via `workspace:provision-complete` + */ +export function registerWorkspaceIpc() { + // Forward service events to the renderer via IPC. + workspaceProviderService.on( + 'provision-progress', + (data: { instanceId: string; line: string }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(WORKSPACE_CHANNELS.PROVISION_PROGRESS, data); + } + } + ); + + workspaceProviderService.on( + 'provision-timeout-warning', + (data: { instanceId: string; timeoutMs: number }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(WORKSPACE_CHANNELS.PROVISION_TIMEOUT_WARNING, data); + } + } + ); + + workspaceProviderService.on( + 'provision-complete', + (data: { instanceId: string; status: string; error?: string }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(WORKSPACE_CHANNELS.PROVISION_COMPLETE, data); + } + } + ); + + // ── workspace:provision ────────────────────────────────────────────── + ipcMain.handle( + WORKSPACE_CHANNELS.PROVISION, + async ( + _, + args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + } + ) => { + try { + const config: ProvisionConfig = { + taskId: args.taskId, + repoUrl: args.repoUrl, + branch: args.branch, + baseRef: args.baseRef, + provisionCommand: args.provisionCommand, + projectPath: args.projectPath, + }; + const instanceId = await workspaceProviderService.provision(config); + return { success: true, data: { instanceId } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] provision failed', { error: message }); + return { success: false, error: message }; + } + } + ); + + // ── workspace:cancel ───────────────────────────────────────────────── + ipcMain.handle(WORKSPACE_CHANNELS.CANCEL, async (_, args: { instanceId: string }) => { + try { + await workspaceProviderService.cancel(args.instanceId); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] cancel failed', { error: message }); + return { success: false, error: message }; + } + }); + + // ── workspace:terminate ────────────────────────────────────────────── + ipcMain.handle( + WORKSPACE_CHANNELS.TERMINATE, + async ( + _, + args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + } + ) => { + try { + await workspaceProviderService.terminate({ + instanceId: args.instanceId, + terminateCommand: args.terminateCommand, + projectPath: args.projectPath, + env: args.env, + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] terminate failed', { error: message }); + return { success: false, error: message }; + } + } + ); + + // ── workspace:status ───────────────────────────────────────────────── + ipcMain.handle(WORKSPACE_CHANNELS.STATUS, async (_, args: { taskId: string }) => { + try { + const instance = await workspaceProviderService.getActiveInstance(args.taskId); + return { success: true, data: instance }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] status failed', { error: message }); + return { success: false, error: message }; + } + }); +} diff --git a/src/main/lib/__tests__/processTree.test.ts b/src/main/lib/__tests__/processTree.test.ts new file mode 100644 index 0000000000..2e28e35a0f --- /dev/null +++ b/src/main/lib/__tests__/processTree.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { getSubtreePids, getSubtreeResources, type ProcessSnapshot } from '../processTree'; + +function makeSnapshot(entries: [number, number, number, number][]): ProcessSnapshot { + const byPid = new Map(); + const childrenOf = new Map(); + for (const [pid, ppid, cpu, memory] of entries) { + byPid.set(pid, { pid, ppid, cpu, memory }); + let children = childrenOf.get(ppid); + if (!children) { + children = []; + childrenOf.set(ppid, children); + } + children.push(pid); + } + return { byPid, childrenOf }; +} + +describe('getSubtreePids', () => { + it('returns empty for missing root', () => { + const snap = makeSnapshot([[1, 0, 0, 0]]); + expect(getSubtreePids(snap, 999)).toEqual([]); + }); + + it('returns single node', () => { + const snap = makeSnapshot([[10, 1, 5, 1024]]); + expect(getSubtreePids(snap, 10)).toEqual([10]); + }); + + it('walks multi-level tree', () => { + const snap = makeSnapshot([ + [1, 0, 1, 100], + [2, 1, 2, 200], + [3, 1, 3, 300], + [4, 2, 4, 400], + ]); + const pids = getSubtreePids(snap, 1).sort(); + expect(pids).toEqual([1, 2, 3, 4]); + }); + + it('handles cycle in ppid without infinite loop', () => { + const snap = makeSnapshot([ + [1, 2, 1, 100], + [2, 1, 2, 200], + ]); + const pids = getSubtreePids(snap, 1).sort(); + expect(pids).toEqual([1, 2]); + }); +}); + +describe('getSubtreeResources', () => { + it('sums cpu and memory across subtree', () => { + const snap = makeSnapshot([ + [1, 0, 10, 1000], + [2, 1, 20, 2000], + [3, 2, 30, 3000], + ]); + const res = getSubtreeResources(snap, 1); + expect(res.cpu).toBe(60); + expect(res.memory).toBe(6000); + expect(res.pids.sort()).toEqual([1, 2, 3]); + }); + + it('returns zeros for missing root', () => { + const snap = makeSnapshot([]); + const res = getSubtreeResources(snap, 42); + expect(res).toEqual({ cpu: 0, memory: 0, pids: [] }); + }); +}); diff --git a/src/main/lib/logger.ts b/src/main/lib/logger.ts index c6e55e853b..fcdca9d38b 100644 --- a/src/main/lib/logger.ts +++ b/src/main/lib/logger.ts @@ -1,5 +1,7 @@ type Level = 'debug' | 'info' | 'warn' | 'error'; +type ConsoleMethod = (...args: any[]) => void; + function envLevel(): Level { const hasDebugFlag = process.argv.includes('--debug-logs') || process.argv.includes('--dev'); if (hasDebugFlag) return 'debug'; @@ -11,29 +13,41 @@ function enabled(target: Level, current: Level): boolean { return order[target] >= order[current]; } +function safeConsoleCall(method: ConsoleMethod, ...args: any[]): void { + try { + method(...args); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'EPIPE') { + return; + } + throw error; + } +} + const current = envLevel(); export const log = { debug: (...args: any[]) => { if (enabled('debug', current)) { // eslint-disable-next-line no-console - console.debug(...args); + safeConsoleCall(console.debug, ...args); } }, info: (...args: any[]) => { if (enabled('info', current)) { // eslint-disable-next-line no-console - console.info(...args); + safeConsoleCall(console.info, ...args); } }, warn: (...args: any[]) => { if (enabled('warn', current)) { // eslint-disable-next-line no-console - console.warn(...args); + safeConsoleCall(console.warn, ...args); } }, error: (...args: any[]) => { // eslint-disable-next-line no-console - console.error(...args); + safeConsoleCall(console.error, ...args); }, }; diff --git a/src/main/lib/processTree.ts b/src/main/lib/processTree.ts new file mode 100644 index 0000000000..dfaaa61fb1 --- /dev/null +++ b/src/main/lib/processTree.ts @@ -0,0 +1,161 @@ +/** + * Lightweight process-tree snapshot. + * + * Uses a single `ps` call on macOS/Linux to capture PID, parent PID, + * %CPU and RSS atomically — no race between "discover children" and + * "read metrics". + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const EXEC_TIMEOUT_MS = 5_000; +const MAX_BUFFER = 10 * 1024 * 1024; + +export interface ProcessInfo { + pid: number; + ppid: number; + /** CPU usage as a percentage (can exceed 100 on multi-core). */ + cpu: number; + /** Resident memory in bytes. */ + memory: number; +} + +export interface ProcessSnapshot { + byPid: Map; + childrenOf: Map; +} + +export interface SubtreeResources { + cpu: number; + memory: number; + pids: number[]; +} + +/** + * Capture an atomic snapshot of all running processes. + * Uses `ps` on macOS/Linux. On Windows, logs a warning and returns an + * empty snapshot (Windows process enumeration is not yet implemented). + */ +export async function captureProcessSnapshot(): Promise { + const raw = await listProcesses(); + const byPid = new Map(); + const childrenOf = new Map(); + + for (const p of raw) { + byPid.set(p.pid, p); + let children = childrenOf.get(p.ppid); + if (!children) { + children = []; + childrenOf.set(p.ppid, children); + } + children.push(p.pid); + } + + return { byPid, childrenOf }; +} + +let warnedWindowsOnce = false; +let warnedUnixOnce = false; + +async function listProcesses(): Promise { + if (process.platform === 'win32') { + if (!warnedWindowsOnce) { + console.warn( + '[perf] Process tree metrics are not yet supported on Windows — PTY sessions will show 0 CPU / 0 memory.' + ); + warnedWindowsOnce = true; + } + return []; + } + return listProcessesUnix(); +} + +/** + * Return every PID that is a descendant of `rootPid` (including + * `rootPid` itself), provided the PID exists in the snapshot. + */ +export function getSubtreePids(snapshot: ProcessSnapshot, rootPid: number): number[] { + const pids: number[] = []; + const stack = [rootPid]; + const visited = new Set(); + + while (stack.length > 0) { + const pid = stack.pop(); + if (pid === undefined || visited.has(pid)) continue; + visited.add(pid); + + if (snapshot.byPid.has(pid)) { + pids.push(pid); + } + const children = snapshot.childrenOf.get(pid); + if (children) { + for (const child of children) { + stack.push(child); + } + } + } + + return pids; +} + +/** + * Sum CPU and memory for the entire process subtree rooted at `rootPid`. + */ +export function getSubtreeResources(snapshot: ProcessSnapshot, rootPid: number): SubtreeResources { + const pids = getSubtreePids(snapshot, rootPid); + let cpu = 0; + let memory = 0; + + for (const pid of pids) { + const info = snapshot.byPid.get(pid); + if (info) { + cpu += info.cpu; + memory += info.memory; + } + } + + return { cpu, memory, pids }; +} + +// ── Platform-specific process listing ───────────────────────────────── + +async function listProcessesUnix(): Promise { + try { + const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,pcpu=,rss='], { + maxBuffer: MAX_BUFFER, + timeout: EXEC_TIMEOUT_MS, + }); + + const result: ProcessInfo[] = []; + for (const line of stdout.split('\n')) { + const t = line.trim(); + if (!t) continue; + + const parts = t.split(/\s+/); + if (parts.length < 4) continue; + + const pid = parseInt(parts[0], 10); + const ppid = parseInt(parts[1], 10); + if (isNaN(pid) || isNaN(ppid)) continue; + + const cpu = parseFloat(parts[2]); + const rssKb = parseInt(parts[3], 10); + + result.push({ + pid, + ppid, + cpu: isFinite(cpu) ? Math.max(0, cpu) : 0, + memory: isFinite(rssKb) ? Math.max(0, rssKb) * 1024 : 0, + }); + } + + return result; + } catch (err) { + if (!warnedUnixOnce) { + warnedUnixOnce = true; + console.warn('[perf] Failed to list processes via ps:', err); + } + return []; + } +} diff --git a/src/main/main.ts b/src/main/main.ts index acb34d5c3f..58ef49f700 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -9,7 +9,6 @@ try { } import { app, BrowserWindow, dialog } from 'electron'; -import { initializeShellEnvironment } from './utils/shellEnv'; // Ensure PATH matches the user's shell when launched from Finder (macOS) // so Homebrew/NPM global binaries like `gh` and `codex` are found. try { @@ -22,28 +21,41 @@ try { } if (process.platform === 'darwin') { - const extras = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/homebrew/sbin', '/usr/local/sbin']; - const cur = process.env.PATH || ''; - const parts = cur.split(':').filter(Boolean); - for (const p of extras) { - if (!parts.includes(p)) parts.unshift(p); - } - process.env.PATH = parts.join(':'); - - // As a last resort, ask the user's login shell for PATH and merge it in. try { - const { execSync } = require('child_process'); - const shell = process.env.SHELL || '/bin/zsh'; - const loginPath = execSync(`${shell} -ilc 'echo -n $PATH'`, { encoding: 'utf8' }); - if (loginPath) { - // Shell noise (nvm messages, ASCII art, motd) gets captured in stdout. - // Split by both : and \n so noise fused with the first real path entry - // (e.g. "nvm output\n/usr/local/bin") is correctly separated. - const allEntries = (loginPath + ':' + process.env.PATH).split(/[:\n]/).filter(Boolean); - const validEntries = allEntries.filter((p: string) => p.startsWith('/')); - const merged = new Set(validEntries); - process.env.PATH = Array.from(merged).join(':'); + const os = require('os'); + const path = require('path'); + const homeDir = os.homedir(); + const extras = [ + path.join(homeDir, '.npm-global/bin'), + path.join(homeDir, '.local/bin'), + path.join(homeDir, '.bun/bin'), + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + '/usr/local/sbin', + ]; + const cur = process.env.PATH || ''; + const parts = cur.split(':').filter(Boolean); + for (const p of extras) { + if (!parts.includes(p)) parts.unshift(p); } + process.env.PATH = parts.join(':'); + + // As a last resort, ask the user's login shell for PATH and merge it in. + try { + const { execSync } = require('child_process'); + const shell = process.env.SHELL || '/bin/zsh'; + const loginPath = execSync(`${shell} -ilc 'echo -n $PATH'`, { encoding: 'utf8' }); + if (loginPath) { + // Shell noise (nvm messages, ASCII art, motd) gets captured in stdout. + // Split by both : and \n so noise fused with the first real path entry + // (e.g. "nvm output\n/usr/local/bin") is correctly separated. + const allEntries = (loginPath + ':' + process.env.PATH).split(/[:\n]/).filter(Boolean); + const validEntries = allEntries.filter((p: string) => p.startsWith('/')); + const merged = new Set(validEntries); + process.env.PATH = Array.from(merged).join(':'); + } + } catch {} } catch {} } @@ -56,6 +68,7 @@ if (process.platform === 'linux') { path.join(homeDir, '.nvm/versions/node', process.version, 'bin'), path.join(homeDir, '.npm-global/bin'), path.join(homeDir, '.local/bin'), + path.join(homeDir, '.bun/bin'), '/usr/local/bin', ]; const cur = process.env.PATH || ''; @@ -102,23 +115,16 @@ if (process.platform === 'win32') { } } -// Detect SSH_AUTH_SOCK from user's shell environment -// This is necessary because GUI-launched apps don't inherit shell env vars -try { - initializeShellEnvironment(); -} catch (error) { - // Silent fail - SSH agent auth will fail if user tries to use it - console.log('[main] Failed to initialize shell environment:', error); -} - import { createMainWindow } from './app/window'; import { registerAppLifecycle } from './app/lifecycle'; import { setupApplicationMenu } from './app/menu'; import { registerAllIpc } from './ipc'; import { databaseService, DatabaseSchemaMismatchError } from './services/DatabaseService'; import { connectionsService } from './services/ConnectionsService'; +import { emdashAccountService } from './services/EmdashAccountService'; import { autoUpdateService } from './services/AutoUpdateService'; import { worktreePoolService } from './services/WorktreePoolService'; +import { workspaceProviderService } from './services/WorkspaceProviderService'; import { sshService } from './services/ssh/SshService'; import { taskLifecycleService } from './services/TaskLifecycleService'; import { agentEventService } from './services/AgentEventService'; @@ -309,11 +315,22 @@ app.whenReady().then(async () => { // Register IPC handlers registerAllIpc(); + try { + await emdashAccountService.loadSessionToken(); + } catch (error) { + console.warn('Failed to load account session:', error); + } + // Clean up any orphaned reserve worktrees from previous sessions worktreePoolService.cleanupOrphanedReserves(localProjectPathsForReserveCleanup).catch((error) => { console.warn('Failed to cleanup orphaned reserves:', error); }); + // Reconcile workspace instances from previous sessions (mark stale as error, check connectivity) + workspaceProviderService.reconcileOnStartup().catch((error) => { + console.warn('Failed to reconcile workspace instances:', error); + }); + // Warm provider installation cache try { await connectionsService.initProviderStatusCache(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 4454b6ca64..09f0790ce9 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -2,6 +2,10 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { TerminalSnapshotPayload } from './types/terminalSnapshot'; import type { OpenInAppId } from '../shared/openInApps'; import type { AgentEvent } from '../shared/agentEvents'; +import type { McpServer } from '../shared/mcp/types'; +import type { DiffPayload } from '../shared/diff/types'; +import type { GitIndexUpdateArgs } from '../shared/git/types'; +import type { ResourceMetricsSnapshot } from '../shared/performanceTypes'; // Keep preload self-contained: sandboxed preload cannot reliably require local runtime modules. const LIFECYCLE_EVENT_CHANNEL = 'lifecycle:event'; @@ -70,8 +74,31 @@ contextBridge.exposeInMainWorld('electronAPI', { return () => handlers.forEach((off) => off()); }, + // Window controls (custom title bar on Windows/Linux) + windowMinimize: () => ipcRenderer.invoke('app:windowMinimize'), + windowMaximize: () => ipcRenderer.invoke('app:windowMaximize'), + windowClose: () => ipcRenderer.invoke('app:windowClose'), + windowIsMaximized: () => ipcRenderer.invoke('app:windowIsMaximized') as Promise, + popupMenu: (args: { label: string; x: number; y: number }) => + ipcRenderer.invoke('app:popupMenu', args), + onWindowMaximizeChange: (listener: (isMaximized: boolean) => void) => { + const onMaximize = () => listener(true); + const onUnmaximize = () => listener(false); + ipcRenderer.on('window:maximized', onMaximize); + ipcRenderer.on('window:unmaximized', onUnmaximize); + return () => { + ipcRenderer.removeListener('window:maximized', onMaximize); + ipcRenderer.removeListener('window:unmaximized', onUnmaximize); + }; + }, + // Open a path in a specific app - openIn: (args: { app: OpenInAppId; path: string }) => ipcRenderer.invoke('app:openIn', args), + openIn: (args: { + app: OpenInAppId; + path: string; + isRemote?: boolean; + sshConnectionId?: string | null; + }) => ipcRenderer.invoke('app:openIn', args), // Check which apps are installed checkInstalledApps: () => @@ -124,6 +151,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ptySaveSnapshot: (args: { id: string; payload: TerminalSnapshotPayload }) => ipcRenderer.invoke('pty:snapshot:save', args), ptyClearSnapshot: (args: { id: string }) => ipcRenderer.invoke('pty:snapshot:clear', args), + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => ipcRenderer.invoke('pty:cleanupSessions', args), onPtyExit: (id: string, listener: (info: { exitCode: number; signal?: number }) => void) => { const channel = `pty:exit:${id}`; const wrapped = (_: Electron.IpcRendererEvent, info: { exitCode: number; signal?: number }) => @@ -137,6 +169,19 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on(channel, wrapped); return () => ipcRenderer.removeListener(channel, wrapped); }, + onPtyActivity: (listener: (data: { id: string; chunk?: string }) => void) => { + const channel = 'pty:activity'; + const wrapped = (_: Electron.IpcRendererEvent, data: { id: string; chunk?: string }) => + listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + onPtyExitGlobal: (listener: (data: { id: string }) => void) => { + const channel = 'pty:exit:global'; + const wrapped = (_: Electron.IpcRendererEvent, data: { id: string }) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, onAgentEvent: (listener: (event: AgentEvent, meta: { appFocused: boolean }) => void) => { const channel = 'agent:event'; const wrapped = ( @@ -211,6 +256,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // Worktree pool (reserve) management for instant task creation worktreeEnsureReserve: (args: { projectId: string; projectPath: string; baseRef?: string }) => ipcRenderer.invoke('worktree:ensureReserve', args), + worktreePreflightReserve: (args: { projectId: string; projectPath: string }) => + ipcRenderer.invoke('worktree:preflightReserve', args), worktreeHasReserve: (args: { projectId: string }) => ipcRenderer.invoke('worktree:hasReserve', args), worktreeClaimReserve: (args: { @@ -243,7 +290,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('lifecycle:setup', args), lifecycleRunStart: (args: { taskId: string; taskPath: string; projectPath: string }) => ipcRenderer.invoke('lifecycle:run:start', args), - lifecycleRunStop: (args: { taskId: string }) => ipcRenderer.invoke('lifecycle:run:stop', args), + lifecycleRunStop: (args: { + taskId: string; + taskPath?: string; + projectPath?: string; + taskName?: string; + }) => ipcRenderer.invoke('lifecycle:run:stop', args), lifecycleTeardown: (args: { taskId: string; taskPath: string; projectPath: string }) => ipcRenderer.invoke('lifecycle:teardown', args), lifecycleGetState: (args: { taskId: string }) => ipcRenderer.invoke('lifecycle:getState', args), @@ -300,10 +352,22 @@ contextBridge.exposeInMainWorld('electronAPI', { relPath: string, remote?: { connectionId: string; remotePath: string } ) => ipcRenderer.invoke('fs:remove', { root, relPath, ...remote }), + fsRename: ( + root: string, + oldName: string, + newName: string, + remote?: { connectionId: string; remotePath: string } + ) => ipcRenderer.invoke('fs:rename', { root, oldName, newName, ...remote }), + fsMkdir: (root: string, relPath: string, remote?: { connectionId: string; remotePath: string }) => + ipcRenderer.invoke('fs:mkdir', { root, relPath, ...remote }), + fsRmdir: (root: string, relPath: string, remote?: { connectionId: string; remotePath: string }) => + ipcRenderer.invoke('fs:rmdir', { root, relPath, ...remote }), getProjectConfig: (projectPath: string) => ipcRenderer.invoke('fs:getProjectConfig', { projectPath }), saveProjectConfig: (projectPath: string, content: string) => ipcRenderer.invoke('fs:saveProjectConfig', { projectPath, content }), + ensureGitignore: (projectPath: string, patterns: string[]) => + ipcRenderer.invoke('fs:ensureGitignore', { projectPath, patterns }), // Attachments saveAttachment: (args: { taskPath: string; srcPath: string; subdir?: string }) => ipcRenderer.invoke('fs:save-attachment', args), @@ -319,10 +383,16 @@ contextBridge.exposeInMainWorld('electronAPI', { fetchProjectBaseRef: (args: { projectId: string; projectPath: string }) => ipcRenderer.invoke('projectSettings:fetchBaseRef', args), getGitInfo: (projectPath: string) => ipcRenderer.invoke('git:getInfo', projectPath), - getGitStatus: (taskPath: string) => ipcRenderer.invoke('git:get-status', taskPath), - watchGitStatus: (taskPath: string) => ipcRenderer.invoke('git:watch-status', taskPath), - unwatchGitStatus: (taskPath: string, watchId?: string) => - ipcRenderer.invoke('git:unwatch-status', taskPath, watchId), + getGitStatus: (arg: string | { taskPath: string; taskId?: string }) => + ipcRenderer.invoke('git:get-status', arg), + getDeleteRisks: (args: { + targets: Array<{ id: string; taskPath: string }>; + includePr?: boolean; + }) => ipcRenderer.invoke('git:get-delete-risks', args), + watchGitStatus: (arg: string | { taskPath: string; taskId?: string }) => + ipcRenderer.invoke('git:watch-status', arg), + unwatchGitStatus: (arg: string | { taskPath: string; taskId?: string }, watchId?: string) => + ipcRenderer.invoke('git:unwatch-status', arg, watchId), onGitStatusChanged: (listener: (data: { taskPath: string; error?: string }) => void) => { attachGitStatusBridgeOnce(); gitStatusChangedListeners.add(listener); @@ -330,16 +400,18 @@ contextBridge.exposeInMainWorld('electronAPI', { gitStatusChangedListeners.delete(listener); }; }, - getFileDiff: (args: { taskPath: string; filePath: string }) => - ipcRenderer.invoke('git:get-file-diff', args), - stageFile: (args: { taskPath: string; filePath: string }) => - ipcRenderer.invoke('git:stage-file', args), - stageAllFiles: (args: { taskPath: string }) => ipcRenderer.invoke('git:stage-all-files', args), - unstageFile: (args: { taskPath: string; filePath: string }) => - ipcRenderer.invoke('git:unstage-file', args), - revertFile: (args: { taskPath: string; filePath: string }) => + getFileDiff: (args: { + taskPath: string; + taskId?: string; + filePath: string; + baseRef?: string; + forceLarge?: boolean; + }) => ipcRenderer.invoke('git:get-file-diff', args), + updateIndex: (args: { taskPath: string; taskId?: string } & GitIndexUpdateArgs) => + ipcRenderer.invoke('git:update-index', args), + revertFile: (args: { taskPath: string; taskId?: string; filePath: string }) => ipcRenderer.invoke('git:revert-file', args), - gitCommit: (args: { taskPath: string; message: string }) => + gitCommit: (args: { taskPath: string; message: string; noVerify?: boolean }) => ipcRenderer.invoke('git:commit', args), gitPush: (args: { taskPath: string }) => ipcRenderer.invoke('git:push', args), gitPull: (args: { taskPath: string }) => ipcRenderer.invoke('git:pull', args), @@ -349,11 +421,16 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('git:get-latest-commit', args), gitGetCommitFiles: (args: { taskPath: string; commitHash: string }) => ipcRenderer.invoke('git:get-commit-files', args), - gitGetCommitFileDiff: (args: { taskPath: string; commitHash: string; filePath: string }) => - ipcRenderer.invoke('git:get-commit-file-diff', args), + gitGetCommitFileDiff: (args: { + taskPath: string; + commitHash: string; + filePath: string; + forceLarge?: boolean; + }) => ipcRenderer.invoke('git:get-commit-file-diff', args), gitSoftReset: (args: { taskPath: string }) => ipcRenderer.invoke('git:soft-reset', args), gitCommitAndPush: (args: { taskPath: string; + taskId?: string; commitMessage?: string; createBranchIfOnDefault?: boolean; branchPrefix?: string; @@ -369,8 +446,10 @@ contextBridge.exposeInMainWorld('electronAPI', { draft?: boolean; web?: boolean; fill?: boolean; + skipPrePush?: boolean; }) => ipcRenderer.invoke('git:create-pr', args), - mergeToMain: (args: { taskPath: string }) => ipcRenderer.invoke('git:merge-to-main', args), + mergeToMain: (args: { taskPath: string; taskId?: string }) => + ipcRenderer.invoke('git:merge-to-main', args), mergePr: (args: { taskPath: string; prNumber?: number; @@ -378,10 +457,17 @@ contextBridge.exposeInMainWorld('electronAPI', { admin?: boolean; }) => ipcRenderer.invoke('git:merge-pr', args), getPrStatus: (args: { taskPath: string }) => ipcRenderer.invoke('git:get-pr-status', args), + enableAutoMerge: (args: { + taskPath: string; + prNumber?: number; + strategy?: 'merge' | 'squash' | 'rebase'; + }) => ipcRenderer.invoke('git:enable-auto-merge', args), + disableAutoMerge: (args: { taskPath: string; prNumber?: number }) => + ipcRenderer.invoke('git:disable-auto-merge', args), getCheckRuns: (args: { taskPath: string }) => ipcRenderer.invoke('git:get-check-runs', args), getPrComments: (args: { taskPath: string; prNumber?: number }) => ipcRenderer.invoke('git:get-pr-comments', args), - getBranchStatus: (args: { taskPath: string }) => + getBranchStatus: (args: { taskPath: string; taskId?: string }) => ipcRenderer.invoke('git:get-branch-status', args), renameBranch: (args: { repoPath: string; oldBranch: string; newBranch: string }) => ipcRenderer.invoke('git:rename-branch', args), @@ -398,8 +484,18 @@ contextBridge.exposeInMainWorld('electronAPI', { setOnboardingSeen: (flag: boolean) => ipcRenderer.invoke('telemetry:set-onboarding-seen', flag), connectToGitHub: (projectPath: string) => ipcRenderer.invoke('github:connect', projectPath), + // Emdash Account + accountGetSession: () => ipcRenderer.invoke('account:getSession'), + accountSignIn: () => ipcRenderer.invoke('account:signIn'), + accountSignOut: () => ipcRenderer.invoke('account:signOut'), + accountCheckServerHealth: () => ipcRenderer.invoke('account:checkServerHealth'), + accountValidateSession: () => ipcRenderer.invoke('account:validateSession'), + + setBadgeCount: (count: number) => ipcRenderer.send('app:set-badge-count', count), + // GitHub integration githubAuth: () => ipcRenderer.invoke('github:auth'), + githubAuthOAuth: () => ipcRenderer.invoke('github:auth:oauth'), githubCancelAuth: () => ipcRenderer.invoke('github:auth:cancel'), // GitHub auth event listeners @@ -462,8 +558,8 @@ contextBridge.exposeInMainWorld('electronAPI', { isPrivate: boolean; gitignoreTemplate?: string; }) => ipcRenderer.invoke('github:createNewProject', params), - githubListPullRequests: (projectPath: string) => - ipcRenderer.invoke('github:listPullRequests', { projectPath }), + githubListPullRequests: (args: { projectPath: string; limit?: number; searchQuery?: string }) => + ipcRenderer.invoke('github:listPullRequests', args), githubCreatePullRequestWorktree: (args: { projectPath: string; projectId: string; @@ -472,6 +568,8 @@ contextBridge.exposeInMainWorld('electronAPI', { taskName?: string; branchName?: string; }) => ipcRenderer.invoke('github:createPullRequestWorktree', args), + githubGetPullRequestBaseDiff: (args: { worktreePath: string; prNumber: number }) => + ipcRenderer.invoke('github:getPullRequestBaseDiff', args), githubLogout: () => ipcRenderer.invoke('github:logout'), githubCheckCLIInstalled: () => ipcRenderer.invoke('github:checkCLIInstalled'), githubInstallCLI: () => ipcRenderer.invoke('github:installCLI'), @@ -506,6 +604,33 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('gitlab:initialFetch', { projectPath, limit }), gitlabSearchIssues: (projectPath: string, searchTerm: string, limit?: number) => ipcRenderer.invoke('gitlab:searchIssues', { projectPath, searchTerm, limit }), + // Plain integration + plainSaveToken: (token: string) => ipcRenderer.invoke('plain:saveToken', token), + plainCheckConnection: () => ipcRenderer.invoke('plain:checkConnection'), + plainClearToken: () => ipcRenderer.invoke('plain:clearToken'), + plainInitialFetch: (limit?: number, statuses?: string[]) => + ipcRenderer.invoke('plain:initialFetch', limit, statuses), + plainSearchThreads: (searchTerm: string, limit?: number) => + ipcRenderer.invoke('plain:searchThreads', searchTerm, limit), + + // Sentry integration + sentrySaveToken: (token: string, organizationSlug?: string) => + ipcRenderer.invoke('sentry:saveToken', token, organizationSlug), + sentryCheckConnection: () => ipcRenderer.invoke('sentry:checkConnection'), + sentryClearToken: () => ipcRenderer.invoke('sentry:clearToken'), + sentryInitialFetch: (limit?: number) => ipcRenderer.invoke('sentry:initialFetch', limit), + sentrySearchIssues: (searchTerm: string, limit?: number) => + ipcRenderer.invoke('sentry:searchIssues', searchTerm, limit), + + // Forgejo integration + forgejoSaveCredentials: (args: { instanceUrl: string; token: string }) => + ipcRenderer.invoke('forgejo:saveCredentials', args), + forgejoClearCredentials: () => ipcRenderer.invoke('forgejo:clearCredentials'), + forgejoCheckConnection: () => ipcRenderer.invoke('forgejo:checkConnection'), + forgejoInitialFetch: (projectPath: string, limit?: number) => + ipcRenderer.invoke('forgejo:initialFetch', { projectPath, limit }), + forgejoSearchIssues: (projectPath: string, searchTerm: string, limit?: number) => + ipcRenderer.invoke('forgejo:searchIssues', { projectPath, searchTerm, limit }), getProviderStatuses: (opts?: { refresh?: boolean; providers?: string[]; providerId?: string }) => ipcRenderer.invoke('providers:getStatuses', opts ?? {}), getProviderCustomConfig: (providerId: string) => @@ -514,19 +639,6 @@ contextBridge.exposeInMainWorld('electronAPI', { updateProviderCustomConfig: (providerId: string, config: any) => ipcRenderer.invoke('providers:updateCustomConfig', providerId, config), - // Line comments management - lineCommentsCreate: (input: any) => ipcRenderer.invoke('lineComments:create', input), - lineCommentsGet: (args: { taskId: string; filePath?: string }) => - ipcRenderer.invoke('lineComments:get', args), - lineCommentsUpdate: (input: { id: string; content: string }) => - ipcRenderer.invoke('lineComments:update', input), - lineCommentsDelete: (id: string) => ipcRenderer.invoke('lineComments:delete', id), - lineCommentsGetFormatted: (taskId: string) => - ipcRenderer.invoke('lineComments:getFormatted', taskId), - lineCommentsMarkSent: (commentIds: string[]) => - ipcRenderer.invoke('lineComments:markSent', commentIds), - lineCommentsGetUnsent: (taskId: string) => ipcRenderer.invoke('lineComments:getUnsent', taskId), - // Debug helpers debugAppendLog: (filePath: string, content: string, options?: { reset?: boolean }) => ipcRenderer.invoke('debug:append-log', filePath, content, options ?? {}), @@ -701,12 +813,148 @@ contextBridge.exposeInMainWorld('electronAPI', { // Skills management skillsGetCatalog: () => ipcRenderer.invoke('skills:getCatalog'), skillsRefreshCatalog: () => ipcRenderer.invoke('skills:refreshCatalog'), - skillsInstall: (args: { skillId: string }) => ipcRenderer.invoke('skills:install', args), + skillsInstall: (args: { skillId: string; source?: { owner: string; repo: string } }) => + ipcRenderer.invoke('skills:install', args), skillsUninstall: (args: { skillId: string }) => ipcRenderer.invoke('skills:uninstall', args), - skillsGetDetail: (args: { skillId: string }) => ipcRenderer.invoke('skills:getDetail', args), + skillsGetDetail: (args: { skillId: string; source?: { owner: string; repo: string } }) => + ipcRenderer.invoke('skills:getDetail', args), skillsGetDetectedAgents: () => ipcRenderer.invoke('skills:getDetectedAgents'), + skillsSearch: (args: { query: string }) => ipcRenderer.invoke('skills:search', args), skillsCreate: (args: { name: string; description: string }) => ipcRenderer.invoke('skills:create', args), + + // Workspace provisioning + workspaceProvision: (args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + }) => ipcRenderer.invoke('workspace:provision', args), + workspaceCancel: (args: { instanceId: string }) => ipcRenderer.invoke('workspace:cancel', args), + workspaceTerminate: (args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + }) => ipcRenderer.invoke('workspace:terminate', args), + workspaceStatus: (args: { taskId: string }) => ipcRenderer.invoke('workspace:status', args), + onWorkspaceProvisionProgress: ( + listener: (data: { instanceId: string; line: string }) => void + ) => { + const channel = 'workspace:provision-progress'; + const wrapped = (_: Electron.IpcRendererEvent, data: { instanceId: string; line: string }) => + listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + onWorkspaceProvisionTimeoutWarning: ( + listener: (data: { instanceId: string; timeoutMs: number }) => void + ) => { + const channel = 'workspace:provision-timeout-warning'; + const wrapped = ( + _: Electron.IpcRendererEvent, + data: { instanceId: string; timeoutMs: number } + ) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + onWorkspaceProvisionComplete: ( + listener: (data: { instanceId: string; status: string; error?: string }) => void + ) => { + const channel = 'workspace:provision-complete'; + const wrapped = ( + _: Electron.IpcRendererEvent, + data: { instanceId: string; status: string; error?: string } + ) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + + // MCP + mcpLoadAll: () => ipcRenderer.invoke('mcp:load-all'), + mcpSaveServer: (server: McpServer) => ipcRenderer.invoke('mcp:save-server', server), + mcpRemoveServer: (serverName: string) => ipcRenderer.invoke('mcp:remove-server', serverName), + mcpGetProviders: () => ipcRenderer.invoke('mcp:get-providers'), + mcpRefreshProviders: () => ipcRenderer.invoke('mcp:refresh-providers'), + + // Automations + automationsList: () => ipcRenderer.invoke('automations:list'), + automationsGet: (args: { id: string }) => ipcRenderer.invoke('automations:get', args), + automationsCreate: (args: { + name: string; + projectId: string; + projectName?: string; + prompt: string; + agentId: string; + mode?: string; + schedule: { + type: string; + hour?: number; + minute?: number; + dayOfWeek?: string; + dayOfMonth?: number; + }; + triggerType?: string; + triggerConfig?: Record; + useWorktree?: boolean; + }) => ipcRenderer.invoke('automations:create', args), + automationsUpdate: (args: { + id: string; + name?: string; + projectId?: string; + projectName?: string; + prompt?: string; + agentId?: string; + mode?: string; + schedule?: { + type: string; + hour?: number; + minute?: number; + dayOfWeek?: string; + dayOfMonth?: number; + }; + triggerType?: string | null; + triggerConfig?: Record | null; + status?: string; + useWorktree?: boolean; + }) => ipcRenderer.invoke('automations:update', args), + automationsDelete: (args: { id: string }) => ipcRenderer.invoke('automations:delete', args), + automationsToggle: (args: { id: string }) => ipcRenderer.invoke('automations:toggle', args), + automationsRunLogs: (args: { automationId: string; limit?: number }) => + ipcRenderer.invoke('automations:runLogs', args), + automationsTriggerNow: (args: { id: string }) => + ipcRenderer.invoke('automations:triggerNow', args), + automationsCompleteRun: (args: { + runLogId: string; + automationId: string; + taskId?: string; + status: 'success' | 'failure'; + error?: string; + }) => ipcRenderer.invoke('automations:completeRun', args), + automationsDrainTriggers: () => ipcRenderer.invoke('automations:drainTriggers'), + onAutomationTriggerAvailable: (listener: () => void) => { + const wrapped = (_: Electron.IpcRendererEvent) => listener(); + ipcRenderer.on('automation:trigger-available', wrapped); + return () => { + ipcRenderer.removeListener('automation:trigger-available', wrapped); + }; + }, + + // Integrations + integrationsStatusMap: () => ipcRenderer.invoke('integrations:statusMap'), + + // Performance Monitor + perfSubscribe: () => ipcRenderer.invoke('perf:subscribe'), + perfUnsubscribe: () => ipcRenderer.invoke('perf:unsubscribe'), + perfGetSnapshot: (mode?: 'interactive' | 'idle') => ipcRenderer.invoke('perf:getSnapshot', mode), + onPerfSnapshot: (listener: (snapshot: ResourceMetricsSnapshot) => void) => { + const channel = 'perf:snapshot'; + const wrapped = (_: Electron.IpcRendererEvent, data: ResourceMetricsSnapshot) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, }); // Type definitions for the exposed API @@ -770,6 +1018,16 @@ export interface ElectronAPI { payload: TerminalSnapshotPayload; }) => Promise<{ ok: boolean; error?: string }>; ptyClearSnapshot: (args: { id: string }) => Promise<{ ok: boolean }>; + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }>; onPtyExit: ( id: string, listener: (info: { exitCode: number; signal?: number }) => void @@ -808,6 +1066,10 @@ export interface ElectronAPI { projectPath: string; baseRef?: string; }) => Promise<{ success: boolean; error?: string }>; + worktreePreflightReserve: (args: { + projectId: string; + projectPath: string; + }) => Promise<{ success: boolean; error?: string }>; worktreeHasReserve: (args: { projectId: string; }) => Promise<{ success: boolean; hasReserve?: boolean; error?: string }>; @@ -863,6 +1125,9 @@ export interface ElectronAPI { }) => Promise<{ success: boolean; skipped?: boolean; error?: string }>; lifecycleRunStop: (args: { taskId: string; + taskPath?: string; + projectPath?: string; + taskName?: string; }) => Promise<{ success: boolean; skipped?: boolean; error?: string }>; lifecycleTeardown: (args: { taskId: string; @@ -920,12 +1185,40 @@ export interface ElectronAPI { changes?: Array<{ path: string; status: string; - additions: number; - deletions: number; + additions: number | null; + deletions: number | null; + isStaged: boolean; diff?: string; }>; error?: string; }>; + getDeleteRisks: (args: { + targets: Array<{ id: string; taskPath: string }>; + includePr?: boolean; + }) => Promise<{ + success: boolean; + risks?: Record< + string, + { + staged: number; + unstaged: number; + untracked: number; + files: string[]; + ahead: number; + behind: number; + error?: string; + pr?: { + number?: number; + title?: string; + url?: string; + state?: string | null; + isDraft?: boolean; + } | null; + prKnown: boolean; + } + >; + error?: string; + }>; watchGitStatus: (taskPath: string) => Promise<{ success: boolean; watchId?: string; @@ -941,9 +1234,23 @@ export interface ElectronAPI { onGitStatusChanged: ( listener: (data: { taskPath: string; error?: string }) => void ) => () => void; - getFileDiff: (args: { taskPath: string; filePath: string }) => Promise<{ + getFileDiff: (args: { + taskPath: string; + filePath: string; + baseRef?: string; + forceLarge?: boolean; + }) => Promise<{ + success: boolean; + diff?: DiffPayload; + error?: string; + }>; + updateIndex: (args: { taskPath: string } & GitIndexUpdateArgs) => Promise<{ success: boolean; - diff?: { lines: Array<{ left?: string; right?: string; type: 'context' | 'add' | 'del' }> }; + error?: string; + }>; + revertFile: (args: { taskPath: string; filePath: string }) => Promise<{ + success: boolean; + action?: 'reverted'; error?: string; }>; gitCommitAndPush: (args: { @@ -961,6 +1268,7 @@ export interface ElectronAPI { draft?: boolean; web?: boolean; fill?: boolean; + skipPrePush?: boolean; }) => Promise<{ success: boolean; url?: string; output?: string; error?: string }>; connectToGitHub: ( projectPath: string @@ -1035,9 +1343,10 @@ export interface ElectronAPI { repoUrl: string, localPath: string ) => Promise<{ success: boolean; error?: string }>; - githubListPullRequests: ( - projectPath: string - ) => Promise<{ success: boolean; prs?: any[]; error?: string }>; + githubListPullRequests: (args: { + projectPath: string; + limit?: number; + }) => Promise<{ success: boolean; prs?: any[]; totalCount?: number; error?: string }>; githubCreatePullRequestWorktree: (args: { projectPath: string; projectId: string; @@ -1050,6 +1359,24 @@ export interface ElectronAPI { worktree?: any; branchName?: string; taskName?: string; + task?: { + id: string; + name: string; + path: string; + branch: string; + projectId: string; + status: string; + agentId: string; + metadata?: { prNumber?: number; prTitle?: string | null }; + }; + error?: string; + }>; + githubGetPullRequestBaseDiff: (args: { worktreePath: string; prNumber: number }) => Promise<{ + success: boolean; + diff?: string; + baseBranch?: string; + headBranch?: string; + prUrl?: string; error?: string; }>; githubLogout: () => Promise; diff --git a/src/main/services/AccountCredentialStore.ts b/src/main/services/AccountCredentialStore.ts new file mode 100644 index 0000000000..86e89348ad --- /dev/null +++ b/src/main/services/AccountCredentialStore.ts @@ -0,0 +1,37 @@ +import { log } from '../lib/logger'; + +const SERVICE_NAME = 'emdash-account'; +const SESSION_ACCOUNT = 'session-token'; + +export class AccountCredentialStore { + async get(): Promise { + try { + const keytar = await import('keytar'); + return await keytar.getPassword(SERVICE_NAME, SESSION_ACCOUNT); + } catch (error) { + log.error('Failed to retrieve session token:', error); + return null; + } + } + + async set(token: string): Promise { + try { + const keytar = await import('keytar'); + await keytar.setPassword(SERVICE_NAME, SESSION_ACCOUNT, token); + } catch (error) { + log.error('Failed to store session token:', error); + throw error; + } + } + + async clear(): Promise { + try { + const keytar = await import('keytar'); + await keytar.deletePassword(SERVICE_NAME, SESSION_ACCOUNT); + } catch (error) { + log.error('Failed to clear session token:', error); + } + } +} + +export const accountCredentialStore = new AccountCredentialStore(); diff --git a/src/main/services/AccountProfileCache.ts b/src/main/services/AccountProfileCache.ts new file mode 100644 index 0000000000..306c52ce7a --- /dev/null +++ b/src/main/services/AccountProfileCache.ts @@ -0,0 +1,44 @@ +import { app } from 'electron'; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { log } from '../lib/logger'; + +const PROFILE_FILENAME = 'emdash-account.json'; + +export interface CachedProfile { + hasAccount: boolean; + userId: string; + username: string; + avatarUrl: string; + email: string; + lastValidated: string; +} + +export class AccountProfileCache { + private getPath(): string { + return join(app.getPath('userData'), PROFILE_FILENAME); + } + + read(): CachedProfile | null { + try { + const filePath = this.getPath(); + if (!existsSync(filePath)) return null; + const data = readFileSync(filePath, 'utf-8'); + return JSON.parse(data) as CachedProfile; + } catch { + return null; + } + } + + write(profile: CachedProfile): void { + try { + const dir = app.getPath('userData'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(this.getPath(), JSON.stringify(profile, null, 2)); + } catch (error) { + log.error('Failed to write profile cache:', error); + } + } +} + +export const accountProfileCache = new AccountProfileCache(); diff --git a/src/main/services/AgentEventService.ts b/src/main/services/AgentEventService.ts index 1a893ae399..9371916d96 100644 --- a/src/main/services/AgentEventService.ts +++ b/src/main/services/AgentEventService.ts @@ -9,6 +9,25 @@ import type { ProviderId } from '@shared/providers/registry'; import type { AgentEvent } from '@shared/agentEvents'; import { getAppSettings } from '../settings'; +function mapProviderNotificationType( + type: string, + providerId: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + raw: Record +): string | undefined { + const explicitNotificationType = raw.notification_type || raw.notificationType; + if (explicitNotificationType) { + return explicitNotificationType; + } + + // Codex emits turn-complete notifications when it is ready for the next user input. + if (type === 'notification' && providerId === 'codex' && raw.type === 'agent-turn-complete') { + return 'idle_prompt'; + } + + return undefined; +} + class AgentEventService { private server: http.Server | null = null; private port = 0; @@ -66,17 +85,21 @@ class AgentEventService { return; } - // Body is the raw Claude Code hook payload JSON + // Body is the raw provider hook payload JSON const raw = body ? JSON.parse(body) : {}; // Normalize snake_case fields from provider hooks to camelCase const normalizedPayload = { ...raw, - notificationType: raw.notification_type ?? raw.notificationType, - lastAssistantMessage: raw.last_assistant_message ?? raw.lastAssistantMessage, + notificationType: mapProviderNotificationType(type, parsed.providerId, raw), + lastAssistantMessage: + raw.last_assistant_message ?? + raw.lastAssistantMessage ?? + raw['last-assistant-message'], }; delete normalizedPayload.notification_type; delete normalizedPayload.last_assistant_message; + delete normalizedPayload['last-assistant-message']; const event: AgentEvent = { type: type as AgentEvent['type'], @@ -165,7 +188,7 @@ class AgentEventService { if (event.type === 'stop') { const notification = new Notification({ title: `${providerName}${titleSuffix}`, - body: 'Your agent has finished working', + body: event.payload.message?.trim() || 'Your agent has finished working', silent: true, }); addClickHandler(notification); @@ -175,7 +198,7 @@ class AgentEventService { if (nt === 'permission_prompt' || nt === 'idle_prompt' || nt === 'elicitation_dialog') { const notification = new Notification({ title: `${providerName}${titleSuffix}`, - body: 'Your agent is waiting for input', + body: event.payload.message?.trim() || 'Your agent is waiting for input', silent: true, }); addClickHandler(notification); diff --git a/src/main/services/AutomationsService.ts b/src/main/services/AutomationsService.ts new file mode 100644 index 0000000000..edc04a28f2 --- /dev/null +++ b/src/main/services/AutomationsService.ts @@ -0,0 +1,1415 @@ +import { and, desc, eq, inArray, lte, sql } from 'drizzle-orm'; +import crypto from 'node:crypto'; +import { getDrizzleClient } from '../db/drizzleClient'; +import { + automationRunLogs as automationRunLogsTable, + automations as automationsTable, +} from '../db/schema'; +import type { AutomationRow, AutomationRunLogRow } from '../db/schema'; +import { log } from '../lib/logger'; +import type { + Automation, + AutomationMode, + AutomationRunLog, + AutomationSchedule, + CreateAutomationInput, + DayOfWeek, + ScheduleType, + TriggerConfig, + TriggerType, + UpdateAutomationInput, +} from '../../shared/automations/types'; + +import { TRIGGER_INTEGRATION_MAP } from '../../shared/automations/types'; + +// --------------------------------------------------------------------------- +// Shared event shape returned by all fetch*() methods +// --------------------------------------------------------------------------- + +interface RawEvent { + id: string; + title: string; + url?: string; + type: string; + extra?: string; + labels?: string[]; + branch?: string; + assignee?: string; +} + +// --------------------------------------------------------------------------- +// AsyncMutex — promise-chaining based mutex for serializing async operations +// --------------------------------------------------------------------------- + +class AsyncMutex { + private chain: Promise = Promise.resolve(); + + async run(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.chain = this.chain.then(async () => { + try { + resolve(await fn()); + } catch (err) { + reject(err); + } + }); + }); + } +} + +// Single mutex for all data operations — avoids fragile nested locking +const dataMutex = new AsyncMutex(); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DAY_ORDER: DayOfWeek[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; +const VALID_SCHEDULE_TYPES: ScheduleType[] = ['hourly', 'daily', 'weekly', 'monthly']; +const VALID_AUTOMATION_STATUS: Automation['status'][] = ['active', 'paused', 'error']; +const VALID_RUN_STATUS: AutomationRunLog['status'][] = ['running', 'success', 'failure']; + +const MAX_RUNS_PER_AUTOMATION = 100; +const MAX_TOTAL_RUNS = 2000; +const DEFAULT_MAX_RUN_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours + +// --------------------------------------------------------------------------- +// Validation & helpers +// --------------------------------------------------------------------------- + +function validateSchedule(schedule: AutomationSchedule): void { + if (!VALID_SCHEDULE_TYPES.includes(schedule.type)) { + throw new Error(`Invalid schedule type: ${schedule.type}`); + } + if (schedule.hour !== undefined && (schedule.hour < 0 || schedule.hour > 23)) { + throw new Error(`Invalid hour: ${schedule.hour} (must be 0-23)`); + } + if (schedule.minute !== undefined && (schedule.minute < 0 || schedule.minute > 59)) { + throw new Error(`Invalid minute: ${schedule.minute} (must be 0-59)`); + } + if (schedule.type === 'weekly' && schedule.dayOfWeek && !DAY_ORDER.includes(schedule.dayOfWeek)) { + throw new Error(`Invalid dayOfWeek: ${schedule.dayOfWeek}`); + } + if (schedule.type === 'monthly') { + const dom = schedule.dayOfMonth ?? 1; + if (dom < 1 || dom > 31) { + throw new Error(`Invalid dayOfMonth: ${dom} (must be 1-31)`); + } + } +} + +function computeNextRun(schedule: AutomationSchedule, fromDate?: Date): string { + const now = fromDate ?? new Date(); + const next = new Date(now); + + const hour = schedule.hour ?? 0; + const minute = schedule.minute ?? 0; + + switch (schedule.type) { + case 'hourly': { + next.setMinutes(minute, 0, 0); + if (next <= now) { + next.setHours(next.getHours() + 1); + } + break; + } + case 'daily': { + next.setHours(hour, minute, 0, 0); + if (next <= now) { + next.setDate(next.getDate() + 1); + } + break; + } + case 'weekly': { + const targetDay = DAY_ORDER.indexOf(schedule.dayOfWeek ?? 'mon'); + const currentDay = next.getDay(); + let daysUntil = targetDay - currentDay; + if (daysUntil < 0) daysUntil += 7; + if (daysUntil === 0) { + next.setHours(hour, minute, 0, 0); + if (next <= now) { + daysUntil = 7; + } + } + if (daysUntil > 0) { + next.setDate(next.getDate() + daysUntil); + } + next.setHours(hour, minute, 0, 0); + break; + } + case 'monthly': { + const desiredDom = schedule.dayOfMonth ?? 1; + // Clamp to the last day of the current month + const daysInCurrentMonth = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + const targetDom = Math.min(desiredDom, daysInCurrentMonth); + next.setDate(targetDom); + next.setHours(hour, minute, 0, 0); + if (next <= now) { + next.setMonth(next.getMonth() + 1); + const daysInNextMonth = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(desiredDom, daysInNextMonth)); + next.setHours(hour, minute, 0, 0); + } + break; + } + } + + return next.toISOString(); +} + +function generateId(): string { + return `auto_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; +} + +function normalizeAutomationStatus(value: unknown): Automation['status'] { + if ( + typeof value === 'string' && + VALID_AUTOMATION_STATUS.includes(value as Automation['status']) + ) { + return value as Automation['status']; + } + return 'active'; +} + +function normalizeRunStatus(value: unknown): AutomationRunLog['status'] { + if (typeof value === 'string' && VALID_RUN_STATUS.includes(value as AutomationRunLog['status'])) { + return value as AutomationRunLog['status']; + } + return 'running'; +} + +// --------------------------------------------------------------------------- +// Row mapping +// --------------------------------------------------------------------------- + +function serializeSchedule(schedule: AutomationSchedule): string { + return JSON.stringify(schedule); +} + +function deserializeSchedule(serialized: string): AutomationSchedule { + const parsed = JSON.parse(serialized) as AutomationSchedule; + validateSchedule(parsed); + return parsed; +} + +function deserializeTriggerConfig(serialized: string | null): TriggerConfig | null { + if (!serialized) return null; + try { + return JSON.parse(serialized) as TriggerConfig; + } catch { + return null; + } +} + +function serializeTriggerConfig(config: TriggerConfig | null | undefined): string | null { + if (!config) return null; + return JSON.stringify(config); +} + +function normalizeMode(value: unknown): AutomationMode { + if (value === 'trigger') return 'trigger'; + return 'schedule'; +} + +function normalizeTriggerType(value: unknown): TriggerType | null { + if (typeof value === 'string' && value in TRIGGER_INTEGRATION_MAP) { + return value as TriggerType; + } + return null; +} + +function mapAutomationRow(row: AutomationRow): Automation { + return { + id: row.id, + name: row.name, + projectId: row.projectId, + projectName: row.projectName, + prompt: row.prompt, + agentId: row.agentId, + mode: normalizeMode(row.mode), + schedule: deserializeSchedule(row.schedule), + triggerType: normalizeTriggerType(row.triggerType), + triggerConfig: deserializeTriggerConfig(row.triggerConfig), + useWorktree: row.useWorktree === 1, + status: normalizeAutomationStatus(row.status), + lastRunAt: row.lastRunAt, + nextRunAt: row.nextRunAt, + runCount: row.runCount, + lastRunResult: + row.lastRunResult === 'success' || row.lastRunResult === 'failure' ? row.lastRunResult : null, + lastRunError: row.lastRunError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function mapRunRow(row: AutomationRunLogRow): AutomationRunLog { + return { + id: row.id, + automationId: row.automationId, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + status: normalizeRunStatus(row.status), + error: row.error, + taskId: row.taskId, + }; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +type AutomationTriggerCallback = (automation: Automation, runLogId: string) => void; +type ReconcileMode = 'startup' | 'resume'; + +class AutomationsService { + private timer: ReturnType | null = null; + private triggerTimer: ReturnType | null = null; + private triggerCallbacks: AutomationTriggerCallback[] = []; + private ticking = false; + private triggerTicking = false; + private reconciling = false; + private initialized = false; + + /** Tracks the last-known event IDs per automation to detect new ones */ + private knownEventIds = new Map>(); + + /** Tracks automations with an in-flight (running) run to prevent overlap */ + private inFlightRuns = new Set(); + + // ------------------------------------------------------------------- + // Initialization — runs once to ensure DB client is ready. + // Tables are created by DatabaseService.ensureMigrations() in production + // via drizzle/0011_add_automations_tables.sql. + // ------------------------------------------------------------------- + + private async ensureInitialized(): Promise { + if (this.initialized) return; + + await getDrizzleClient(); + this.initialized = true; + } + + /** Reset internal state — test-only, not part of the public API. */ + _resetForTesting(): void { + this.initialized = false; + this.ticking = false; + this.triggerTicking = false; + this.reconciling = false; + this.triggerCallbacks = []; + this.knownEventIds.clear(); + this.inFlightRuns.clear(); + this.stop(); + } + + // ------------------------------------------------------------------- + // Scheduler + // ------------------------------------------------------------------- + + onTrigger(cb: AutomationTriggerCallback): void { + this.triggerCallbacks.push(cb); + } + + start(): void { + if (this.timer) return; + log.info('[Automations] Scheduler started'); + this.timer = setInterval(() => void this.tick(), 30_000); + void this.tick(); + + // Trigger polling every 10s. This is safe because: + // 1. GitHub Events API with ETag caching → 304 responses when nothing changed (nearly free) + // 2. Fetch results are deduplicated per project+triggerType within each cycle + // 3. GitHub's Events API recommends X-Poll-Interval of ~10s + if (!this.triggerTimer) { + this.triggerTimer = setInterval(() => void this.tickTriggers(), 10_000); + // First trigger poll after a short delay to let integrations initialize + setTimeout(() => void this.tickTriggers(), 2_000); + } + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + if (this.triggerTimer) { + clearInterval(this.triggerTimer); + this.triggerTimer = null; + } + log.info('[Automations] Scheduler stopped'); + } + + // Prevent overlapping ticks — if the previous tick is still running, skip + private async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + await this.executeTick(); + } catch (err) { + log.error('[Automations] Tick failed:', err); + } finally { + this.ticking = false; + } + } + + private async executeTick(): Promise { + const triggers: Array<{ automation: Automation; runLogId: string }> = []; + + await dataMutex.run(async () => { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + const now = new Date(); + const nowIso = now.toISOString(); + + const dueRows = await db + .select() + .from(automationsTable) + .where(and(eq(automationsTable.status, 'active'), lte(automationsTable.nextRunAt, nowIso))); + + for (const row of dueRows) { + const automation = mapAutomationRow(row); + if (!automation.nextRunAt) continue; + + // Skip if a previous run is still in-flight to prevent overlap + if (this.inFlightRuns.has(automation.id)) continue; + + const runLogId = generateId(); + const nextRunAt = computeNextRun(automation.schedule, now); + const nextRunCount = automation.runCount + 1; + + await db + .update(automationsTable) + .set({ + lastRunAt: nowIso, + runCount: nextRunCount, + nextRunAt, + updatedAt: nowIso, + }) + .where(eq(automationsTable.id, automation.id)); + + await this.insertRunLog({ + id: runLogId, + automationId: automation.id, + startedAt: nowIso, + finishedAt: null, + status: 'running', + error: null, + taskId: null, + }); + + this.inFlightRuns.add(automation.id); + + triggers.push({ + automation: { + ...automation, + lastRunAt: nowIso, + runCount: nextRunCount, + nextRunAt, + updatedAt: nowIso, + }, + runLogId, + }); + } + }); + + await this.dispatchTriggers(triggers, 'Trigger callback failed'); + } + + // ------------------------------------------------------------------- + // Event-trigger polling + // ------------------------------------------------------------------- + + private async tickTriggers(): Promise { + if (this.triggerTicking) return; + this.triggerTicking = true; + try { + await this.executeTriggerPoll(); + } catch (err) { + log.error('[Automations] Trigger poll failed:', err); + } finally { + this.triggerTicking = false; + } + } + + private async executeTriggerPoll(): Promise { + // Read active trigger automations under mutex to avoid TOCTOU with deletes/updates + const activeAutomations: Automation[] = await dataMutex.run(async () => { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(automationsTable) + .where(and(eq(automationsTable.status, 'active'), eq(automationsTable.mode, 'trigger'))); + return rows.map(mapAutomationRow); + }); + + if (activeAutomations.length === 0) return; + + // Per-cycle fetch cache: avoids duplicate API calls when multiple automations + // watch the same project+triggerType (e.g. 5 automations on the same repo). + const fetchCache = new Map>(); + + const triggers: Array<{ automation: Automation; runLogId: string }> = []; + + for (const automation of activeAutomations) { + if (!automation.triggerType) continue; + + try { + const newEvents = await this.fetchNewEventsCached(automation, fetchCache); + if (newEvents.length === 0) continue; + + for (const event of newEvents) { + const runLogId = generateId(); + const nowIso = new Date().toISOString(); + + await dataMutex.run(async () => { + const { db: freshDb } = await getDrizzleClient(); + await freshDb + .update(automationsTable) + .set({ + lastRunAt: nowIso, + runCount: sql`${automationsTable.runCount} + 1`, + updatedAt: nowIso, + }) + .where(eq(automationsTable.id, automation.id)); + + await this.insertRunLog({ + id: runLogId, + automationId: automation.id, + startedAt: nowIso, + finishedAt: null, + status: 'running', + error: null, + taskId: null, + }); + }); + + const enrichedPrompt = this.enrichPromptWithEvent(automation.prompt, event); + triggers.push({ + automation: { + ...automation, + prompt: enrichedPrompt, + lastRunAt: new Date().toISOString(), + runCount: automation.runCount + 1, + }, + runLogId, + }); + } + } catch (err) { + log.error(`[Automations] Trigger poll failed for "${automation.name}":`, err); + await this.setLastRunResult( + automation.id, + 'failure', + err instanceof Error ? err.message : String(err) + ); + } + } + + await this.dispatchTriggers(triggers, 'Trigger callback failed'); + } + + private enrichPromptWithEvent( + basePrompt: string, + event: Pick + ): string { + const contextLines: string[] = []; + contextLines.push(`[Triggered by ${event.type}: "${event.title}"]`); + if (event.url) contextLines.push(`URL: ${event.url}`); + if (event.extra) contextLines.push(event.extra); + return `${contextLines.join('\n')}\n\n${basePrompt}`; + } + + /** + * Fetch new events for an automation, using a per-cycle cache to deduplicate + * API calls when multiple automations watch the same project + trigger type. + */ + private async fetchNewEventsCached( + automation: Automation, + cache: Map> + ): Promise { + const known = this.knownEventIds.get(automation.id) ?? new Set(); + const newEvents: RawEvent[] = []; + + try { + const cacheKey = `${automation.projectId}::${automation.triggerType}`; + let eventsPromise = cache.get(cacheKey); + if (!eventsPromise) { + eventsPromise = this.fetchRawEvents(automation); + cache.set(cacheKey, eventsPromise); + } + const rawEvents = await eventsPromise; + + if (!this.knownEventIds.has(automation.id)) { + // First poll: seed the known set without triggering + this.knownEventIds.set(automation.id, new Set(rawEvents.map((e) => e.id))); + log.info( + `[Automations] Seeded ${rawEvents.length} known events for "${automation.name}" (${automation.triggerType})` + ); + return []; + } + + for (const event of rawEvents) { + if (!known.has(event.id)) { + if (this.matchesTriggerFilters(event, automation.triggerConfig)) { + newEvents.push(event); + } + known.add(event.id); + } + } + + // Cap the known set to prevent memory bloat + if (known.size > 5000) { + const entries = Array.from(known); + const toRemove = entries.slice(0, entries.length - 2000); + for (const id of toRemove) known.delete(id); + } + + this.knownEventIds.set(automation.id, known); + } catch (err) { + log.error(`[Automations] fetchNewEvents failed for "${automation.name}":`, err); + } + + return newEvents; + } + + private matchesTriggerFilters(event: RawEvent, config: TriggerConfig | null): boolean { + if (!config) return true; + + if (config.labelFilter && config.labelFilter.length > 0) { + if (!event.labels || event.labels.length === 0) return false; + const hasMatchingLabel = config.labelFilter.some((f) => + event.labels!.some((l) => l.toLowerCase() === f.toLowerCase()) + ); + if (!hasMatchingLabel) return false; + } + + if (config.branchFilter) { + if (!event.branch) return false; + const pattern = config.branchFilter; + if (pattern.includes('*')) { + // Escape regex special chars, then convert glob * to .* + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + const regex = new RegExp('^' + escaped + '$'); + if (!regex.test(event.branch)) return false; + } else { + if (event.branch !== pattern) return false; + } + } + + if (config.assigneeFilter) { + if (!event.assignee) return false; + if (event.assignee.toLowerCase() !== config.assigneeFilter.toLowerCase()) return false; + } + + return true; + } + + private async fetchRawEvents(automation: Automation): Promise { + if (!automation.triggerType) return []; + + // Resolve project path (needed for GitHub, GitLab, Forgejo) + const { databaseService } = await import('./DatabaseService'); + const projects = await databaseService.getProjects(); + const project = projects.find((p) => p.id === automation.projectId); + const projectPath = project?.path; + + try { + switch (automation.triggerType) { + case 'github_pr': + return await this.fetchGitHubEvents(projectPath, 'PullRequestEvent'); + case 'github_issue': + return await this.fetchGitHubEvents(projectPath, 'IssuesEvent'); + case 'linear_issue': + return await this.fetchLinearEvents(); + case 'jira_issue': + return await this.fetchJiraEvents(); + case 'gitlab_issue': + return await this.fetchGitLabEvents(projectPath, 'issue'); + case 'gitlab_mr': + return await this.fetchGitLabEvents(projectPath, 'mr'); + case 'forgejo_issue': + return await this.fetchForgejoEvents(projectPath); + case 'plain_thread': + return await this.fetchPlainEvents(); + case 'sentry_issue': + return await this.fetchSentryEvents(); + default: + return []; + } + } catch (err) { + const integration = TRIGGER_INTEGRATION_MAP[automation.triggerType]; + log.error(`[Automations] Fetch failed for "${automation.name}" (${integration}):`, err); + return []; + } + } + + // ------------------------------------------------------------------- + // Per-integration event fetchers — each checks connection first + // ------------------------------------------------------------------- + + private async fetchGitHubEvents( + projectPath: string | undefined, + eventType: 'IssuesEvent' | 'PullRequestEvent' + ): Promise { + if (!projectPath) return []; + const { githubService: gh } = await import('./GitHubService'); + if (!(await gh.isAuthenticated())) return []; + + const repoEvents = await gh.fetchRepoEvents(projectPath, [eventType]); + return repoEvents.map((event) => ({ + id: event.id, + title: event.title, + url: event.url, + type: eventType === 'IssuesEvent' ? 'GitHub Issue' : 'GitHub PR', + extra: `${eventType === 'IssuesEvent' ? 'Issue' : 'PR'} #${event.number}`, + labels: event.labels, + branch: event.branch, + assignee: event.assignee, + })); + } + + private async fetchLinearEvents(): Promise { + const { default: LinearService } = await import('./LinearService'); + const linear = new LinearService(); + const status = await linear.checkConnection(); + if (!status.connected) return []; + + const issues = await linear.initialFetch(30); + return issues.map((issue: any) => ({ + id: `linear-${issue.id}`, + title: issue.title ?? '', + url: issue.url, + type: 'Linear Issue', + extra: issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title, + assignee: issue.assignee?.displayName ?? issue.assignee?.name ?? undefined, + })); + } + + private async fetchJiraEvents(): Promise { + const JiraService = (await import('./JiraService')).default; + const jira = new JiraService(); + const status = await jira.checkConnection(); + if (!status.connected) return []; + + const issues = await jira.initialFetch(30); + return issues.map((issue: any) => ({ + id: `jira-${issue.id ?? issue.key}`, + title: issue.title ?? issue.summary ?? '', + url: issue.url ?? issue.self ?? undefined, + type: 'Jira Issue', + extra: issue.key ? `${issue.key}: ${issue.title ?? issue.summary}` : (issue.title ?? ''), + labels: issue.labels, + assignee: issue.assignee?.displayName ?? issue.assignee?.name ?? undefined, + })); + } + + private async fetchGitLabEvents( + projectPath: string | undefined, + kind: 'issue' | 'mr' + ): Promise { + if (!projectPath) return []; + const { GitLabService } = await import('./GitLabService'); + const gitlab = new GitLabService(); + const connStatus = await gitlab.checkConnection(); + if (!connStatus.success) return []; + + if (kind === 'issue') { + const result = await gitlab.initialFetch(projectPath, 30); + if (!result.success || !result.issues) return []; + return result.issues.map((issue: any) => ({ + id: `gitlab-issue-${issue.id ?? issue.iid}`, + title: issue.title ?? '', + url: issue.web_url ?? undefined, + type: 'GitLab Issue', + extra: issue.iid ? `#${issue.iid}: ${issue.title}` : issue.title, + labels: issue.labels ?? [], + assignee: issue.assignee?.name ?? issue.assignee?.username ?? undefined, + })); + } + + const mrResult = await gitlab.initialFetchMRs(projectPath, 30); + if (!mrResult.success || !mrResult.mrs) return []; + return mrResult.mrs.map((mr: any) => ({ + id: `gitlab-mr-${mr.id}`, + title: mr.title ?? '', + url: mr.web_url ?? undefined, + type: 'GitLab MR', + extra: mr.iid ? `!${mr.iid}: ${mr.title}` : mr.title, + labels: mr.labels ?? [], + branch: mr.source_branch ?? undefined, + assignee: mr.assignee?.name ?? mr.assignee?.username ?? undefined, + })); + } + + private async fetchForgejoEvents(projectPath: string | undefined): Promise { + if (!projectPath) return []; + const { ForgejoService } = await import('./ForgejoService'); + const forgejo = new ForgejoService(); + const connStatus = await forgejo.checkConnection(); + if (!connStatus.success) return []; + + const result = await forgejo.initialFetch(projectPath, 30); + if (!result.success || !result.issues) return []; + return result.issues.map((issue: any) => ({ + id: `forgejo-${issue.id ?? issue.number}`, + title: issue.title ?? '', + url: issue.html_url ?? issue.url ?? undefined, + type: 'Forgejo Issue', + extra: issue.number ? `#${issue.number}: ${issue.title}` : issue.title, + labels: issue.labels?.map((l: any) => l?.name ?? l).filter(Boolean) ?? [], + assignee: issue.assignee?.login ?? issue.assignee?.username ?? undefined, + })); + } + + private async fetchPlainEvents(): Promise { + const { default: PlainService } = await import('./PlainService'); + const plain = new PlainService(); + const status = await plain.checkConnection(); + if (!status.connected) return []; + + const threads = await plain.initialFetch(30); + return threads.map((thread: any) => ({ + id: `plain-${thread.id}`, + title: thread.title ?? thread.subject ?? '', + url: thread.url ?? undefined, + type: 'Plain Thread', + extra: thread.title ?? thread.subject ?? '', + assignee: thread.assignee?.name ?? thread.assignee?.email ?? undefined, + })); + } + + private async fetchSentryEvents(): Promise { + const { sentryService } = await import('./SentryService'); + + let issues: import('./SentryService').SentryIssue[]; + try { + issues = await sentryService.initialFetch(30); + } catch { + return []; + } + return issues.map((issue) => ({ + id: `sentry-${issue.id}`, + title: issue.title ?? '', + url: issue.permalink ?? undefined, + type: 'Sentry Issue', + extra: issue.shortId + ? `${issue.shortId}: ${issue.title}${issue.culprit ? ` in ${issue.culprit}` : ''}` + : issue.title, + labels: issue.level ? [issue.level] : undefined, + assignee: issue.assignedTo?.name ?? issue.assignedTo?.email ?? undefined, + })); + } + + // ------------------------------------------------------------------- + // Run log internals — always called under dataMutex + // ------------------------------------------------------------------- + + /** + * Insert a run log and enforce per-automation and global retention limits. + * Must be called while dataMutex is held. + */ + private async insertRunLog(runLog: AutomationRunLog): Promise { + const { db } = await getDrizzleClient(); + + await db + .insert(automationRunLogsTable) + .values({ + id: runLog.id, + automationId: runLog.automationId, + startedAt: runLog.startedAt, + finishedAt: runLog.finishedAt, + status: runLog.status, + error: runLog.error, + taskId: runLog.taskId, + }) + .onConflictDoNothing(); + + // Enforce per-automation limit + const perAutomationRows = await db + .select({ id: automationRunLogsTable.id }) + .from(automationRunLogsTable) + .where(eq(automationRunLogsTable.automationId, runLog.automationId)) + .orderBy(desc(automationRunLogsTable.startedAt), desc(automationRunLogsTable.id)); + + if (perAutomationRows.length > MAX_RUNS_PER_AUTOMATION) { + const idsToDelete = perAutomationRows.slice(MAX_RUNS_PER_AUTOMATION).map((row) => row.id); + await db + .delete(automationRunLogsTable) + .where(inArray(automationRunLogsTable.id, idsToDelete)); + } + + // Enforce global limit + const allRows = await db + .select({ id: automationRunLogsTable.id }) + .from(automationRunLogsTable) + .orderBy(desc(automationRunLogsTable.startedAt), desc(automationRunLogsTable.id)); + + if (allRows.length > MAX_TOTAL_RUNS) { + const idsToDelete = allRows.slice(MAX_TOTAL_RUNS).map((row) => row.id); + await db + .delete(automationRunLogsTable) + .where(inArray(automationRunLogsTable.id, idsToDelete)); + } + } + + // ------------------------------------------------------------------- + // Public CRUD + // ------------------------------------------------------------------- + + async list(): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(automationsTable) + .orderBy(sql`rowid asc`); + return rows.map(mapAutomationRow); + } + + async get(id: string): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(automationsTable) + .where(eq(automationsTable.id, id)) + .limit(1); + const row = rows[0]; + return row ? mapAutomationRow(row) : null; + } + + async create(input: CreateAutomationInput): Promise { + const mode: AutomationMode = input.mode ?? 'schedule'; + if (mode === 'schedule') { + validateSchedule(input.schedule); + } + if (mode === 'trigger' && !input.triggerType) { + throw new Error('triggerType is required when mode is "trigger"'); + } + await this.ensureInitialized(); + + const now = new Date().toISOString(); + const isTrigger = mode === 'trigger'; + const automation: Automation = { + id: generateId(), + name: input.name, + projectId: input.projectId, + projectName: input.projectName ?? '', + prompt: input.prompt, + agentId: input.agentId, + mode, + schedule: input.schedule, + triggerType: isTrigger ? (input.triggerType ?? null) : null, + triggerConfig: isTrigger ? (input.triggerConfig ?? null) : null, + useWorktree: input.useWorktree ?? true, + status: 'active', + lastRunAt: null, + nextRunAt: isTrigger ? null : computeNextRun(input.schedule), + runCount: 0, + lastRunResult: null, + lastRunError: null, + createdAt: now, + updatedAt: now, + }; + + const { db } = await getDrizzleClient(); + await db.insert(automationsTable).values({ + id: automation.id, + projectId: automation.projectId, + projectName: automation.projectName, + name: automation.name, + prompt: automation.prompt, + agentId: automation.agentId, + mode: automation.mode, + schedule: serializeSchedule(automation.schedule), + triggerType: automation.triggerType, + triggerConfig: serializeTriggerConfig(automation.triggerConfig), + useWorktree: automation.useWorktree ? 1 : 0, + status: automation.status, + lastRunAt: automation.lastRunAt, + nextRunAt: automation.nextRunAt, + runCount: automation.runCount, + lastRunResult: automation.lastRunResult, + lastRunError: automation.lastRunError, + createdAt: automation.createdAt, + updatedAt: automation.updatedAt, + }); + + log.info(`[Automations] Created automation: ${automation.name} (${automation.id})`); + + // For trigger-based automations, immediately seed known events so the next + // poll cycle can detect new events right away (instead of wasting a cycle on seeding). + if (isTrigger) { + void this.seedAutomationEvents(automation); + } + + return automation; + } + + /** + * Pre-seed known events for a trigger automation so the very next poll + * cycle can detect genuinely new events instead of treating everything as "first seen". + */ + private async seedAutomationEvents(automation: Automation): Promise { + try { + const rawEvents = await this.fetchRawEvents(automation); + this.knownEventIds.set(automation.id, new Set(rawEvents.map((e) => e.id))); + log.info( + `[Automations] Pre-seeded ${rawEvents.length} events for "${automation.name}" (${automation.triggerType})` + ); + } catch (err) { + log.warn(`[Automations] Failed to pre-seed events for "${automation.name}":`, err); + } + } + + async update(input: UpdateAutomationInput): Promise { + if (input.schedule) { + validateSchedule(input.schedule); + } + + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + const rows = await db + .select() + .from(automationsTable) + .where(eq(automationsTable.id, input.id)) + .limit(1); + const row = rows[0]; + if (!row) return null; + + const current = mapAutomationRow(row); + const nextMode = input.mode ?? current.mode; + const nextSchedule = input.schedule ?? current.schedule; + const nextUpdatedAt = new Date().toISOString(); + const isTrigger = nextMode === 'trigger'; + + const updated: Automation = { + ...current, + name: input.name ?? current.name, + projectId: input.projectId ?? current.projectId, + projectName: input.projectName ?? current.projectName, + prompt: input.prompt ?? current.prompt, + agentId: input.agentId ?? current.agentId, + mode: nextMode, + status: input.status ?? current.status, + useWorktree: input.useWorktree ?? current.useWorktree, + schedule: nextSchedule, + triggerType: + input.triggerType !== undefined + ? input.triggerType + : isTrigger + ? current.triggerType + : null, + triggerConfig: + input.triggerConfig !== undefined + ? input.triggerConfig + : isTrigger + ? current.triggerConfig + : null, + nextRunAt: isTrigger + ? null + : input.schedule + ? computeNextRun(nextSchedule) + : current.nextRunAt, + updatedAt: nextUpdatedAt, + }; + + await db + .update(automationsTable) + .set({ + name: updated.name, + projectId: updated.projectId, + projectName: updated.projectName, + prompt: updated.prompt, + agentId: updated.agentId, + mode: updated.mode, + schedule: serializeSchedule(updated.schedule), + triggerType: updated.triggerType, + triggerConfig: serializeTriggerConfig(updated.triggerConfig), + useWorktree: updated.useWorktree ? 1 : 0, + status: updated.status, + nextRunAt: updated.nextRunAt, + updatedAt: updated.updatedAt, + }) + .where(eq(automationsTable.id, updated.id)); + + log.info(`[Automations] Updated automation: ${updated.name} (${updated.id})`); + + // Re-seed when trigger type changed, automation switched to trigger mode, + // or the project context changed (so stale events aren't replayed). + const triggerTypeChanged = + updated.mode === 'trigger' && + (input.triggerType !== undefined || input.mode === 'trigger') && + updated.triggerType !== current.triggerType; + const switchedToTrigger = input.mode === 'trigger' && current.mode !== 'trigger'; + const projectChanged = + updated.mode === 'trigger' && + input.projectId !== undefined && + input.projectId !== current.projectId; + + if (triggerTypeChanged || switchedToTrigger || projectChanged) { + this.knownEventIds.delete(updated.id); + void this.seedAutomationEvents(updated); + } + + return updated; + } + + async delete(id: string): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + const before = await db + .select({ id: automationsTable.id }) + .from(automationsTable) + .where(eq(automationsTable.id, id)) + .limit(1); + if (before.length === 0) return false; + + await db.delete(automationRunLogsTable).where(eq(automationRunLogsTable.automationId, id)); + await db.delete(automationsTable).where(eq(automationsTable.id, id)); + this.knownEventIds.delete(id); + log.info(`[Automations] Deleted automation: ${id}`); + return true; + } + + async toggleStatus(id: string): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + const rows = await db + .select() + .from(automationsTable) + .where(eq(automationsTable.id, id)) + .limit(1); + const row = rows[0]; + if (!row) return null; + + const automation = mapAutomationRow(row); + const nextStatus: Automation['status'] = automation.status === 'active' ? 'paused' : 'active'; + const nowIso = new Date().toISOString(); + + const updated: Automation = { + ...automation, + status: nextStatus, + nextRunAt: + nextStatus === 'active' && automation.mode === 'schedule' + ? computeNextRun(automation.schedule) + : automation.mode === 'trigger' + ? null + : automation.nextRunAt, + lastRunError: nextStatus === 'active' ? null : automation.lastRunError, + updatedAt: nowIso, + }; + + await db + .update(automationsTable) + .set({ + status: updated.status, + nextRunAt: updated.nextRunAt, + lastRunError: updated.lastRunError, + updatedAt: updated.updatedAt, + }) + .where(eq(automationsTable.id, id)); + + // Re-seed known events when a trigger automation is re-activated so stale + // issues/PRs created while paused don't fire as false positives. + if (nextStatus === 'active' && updated.mode === 'trigger') { + this.knownEventIds.delete(updated.id); + void this.seedAutomationEvents(updated); + } + + return updated; + } + + // ------------------------------------------------------------------- + // Run logs — public API + // ------------------------------------------------------------------- + + async getRunLogs(automationId: string, limit = 20): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + const rows = await db + .select() + .from(automationRunLogsTable) + .where(eq(automationRunLogsTable.automationId, automationId)) + .orderBy(desc(automationRunLogsTable.startedAt), desc(automationRunLogsTable.id)) + .limit(limit); + + return rows.map(mapRunRow); + } + + async updateRunLog( + runId: string, + update: Partial>, + automationId?: string + ): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + // Drizzle skips undefined values in .set() automatically + await db + .update(automationRunLogsTable) + .set({ + status: update.status, + error: update.error, + finishedAt: update.finishedAt, + taskId: update.taskId, + }) + .where(eq(automationRunLogsTable.id, runId)); + + // Clear in-flight tracking when the run finishes + if (automationId && (update.status === 'success' || update.status === 'failure')) { + this.inFlightRuns.delete(automationId); + } + } + + async setLastRunResult( + automationId: string, + result: 'success' | 'failure', + error?: string + ): Promise { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + await db + .update(automationsTable) + .set({ + lastRunResult: result, + lastRunError: error ?? null, + updatedAt: new Date().toISOString(), + }) + .where(eq(automationsTable.id, automationId)); + } + + async createManualRunLog(automationId: string): Promise { + const runLogId = generateId(); + const nowIso = new Date().toISOString(); + + await dataMutex.run(async () => { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + + await this.insertRunLog({ + id: runLogId, + automationId, + startedAt: nowIso, + finishedAt: null, + status: 'running', + error: null, + taskId: null, + }); + + this.inFlightRuns.add(automationId); + + const rows = await db + .select({ runCount: automationsTable.runCount }) + .from(automationsTable) + .where(eq(automationsTable.id, automationId)) + .limit(1); + + if (rows[0]) { + await db + .update(automationsTable) + .set({ + runCount: rows[0].runCount + 1, + lastRunAt: nowIso, + updatedAt: nowIso, + }) + .where(eq(automationsTable.id, automationId)); + } + }); + + return runLogId; + } + + /** + * Reconcile state after an app restart: + * 1. Mark orphaned "running" run logs as failed (app was closed or timed out). + * 2. Catch-up: trigger missed automations exactly once each, regardless of + * how many scheduled occurrences were skipped while the app was closed. + * 3. Recalculate nextRunAt to the next future occurrence. + * + * Triggers are collected under the mutex and fired afterwards so that + * callbacks never run while the lock is held. + */ + async reconcileMissedRuns(): Promise { + await this.reconcileMissedRunsWithMode('startup'); + } + + /** + * Catch up missed schedules after the machine resumes from sleep. + * + * Unlike startup reconciliation, this keeps live in-flight runs intact. + */ + async reconcileMissedRunsAfterResume(): Promise { + await this.reconcileMissedRunsWithMode('resume'); + } + + private async reconcileMissedRunsWithMode(mode: ReconcileMode): Promise { + if (this.reconciling) return; + this.reconciling = true; + + try { + const triggers: Array<{ automation: Automation; runLogId: string }> = []; + + await dataMutex.run(async () => { + await this.ensureInitialized(); + const { db } = await getDrizzleClient(); + const now = new Date(); + const nowIso = now.toISOString(); + + if (mode === 'startup') { + // Only cleanup orphaned runs on cold start. On resume, a "running" row + // can still correspond to a live task that was merely suspended. + const runningRows = await db + .select() + .from(automationRunLogsTable) + .where(eq(automationRunLogsTable.status, 'running')); + + const affectedAutomationErrors = new Map(); + + for (const row of runningRows) { + const startedAt = new Date(row.startedAt); + const elapsed = now.getTime() - startedAt.getTime(); + + const nextError = + elapsed > DEFAULT_MAX_RUN_DURATION_MS + ? `Run timed out after ${Math.round(elapsed / 60_000)} minutes` + : 'Interrupted (app was closed or crashed)'; + + await db + .update(automationRunLogsTable) + .set({ + status: 'failure', + error: nextError, + finishedAt: nowIso, + }) + .where(eq(automationRunLogsTable.id, row.id)); + + this.inFlightRuns.delete(row.automationId); + + const existingError = affectedAutomationErrors.get(row.automationId); + if (!existingError || nextError.startsWith('Run timed out after')) { + affectedAutomationErrors.set(row.automationId, nextError); + } + } + + if (affectedAutomationErrors.size > 0) { + for (const [automationId, lastRunError] of affectedAutomationErrors) { + await db + .update(automationsTable) + .set({ + lastRunResult: 'failure', + lastRunError, + updatedAt: nowIso, + }) + .where(eq(automationsTable.id, automationId)); + } + } + } + + // Catch up missed schedules. Live in-flight runs still block overlap, + // matching the normal scheduler behavior after resume. + const dueRows = await db + .select() + .from(automationsTable) + .where( + and(eq(automationsTable.status, 'active'), lte(automationsTable.nextRunAt, nowIso)) + ); + + for (const row of dueRows) { + const automation = mapAutomationRow(row); + if (!automation.nextRunAt) continue; + if (this.inFlightRuns.has(automation.id)) continue; + + const nextRun = new Date(automation.nextRunAt); + if (nextRun >= now) continue; + + const runLogId = generateId(); + const recalculatedNextRun = computeNextRun(automation.schedule, now); + const nextRunCount = automation.runCount + 1; + + await db + .update(automationsTable) + .set({ + lastRunAt: nowIso, + runCount: nextRunCount, + nextRunAt: recalculatedNextRun, + updatedAt: nowIso, + }) + .where(eq(automationsTable.id, automation.id)); + + await this.insertRunLog({ + id: runLogId, + automationId: automation.id, + startedAt: nowIso, + finishedAt: null, + status: 'running', + error: null, + taskId: null, + }); + + this.inFlightRuns.add(automation.id); + + triggers.push({ + automation: { + ...automation, + lastRunAt: nowIso, + runCount: nextRunCount, + nextRunAt: recalculatedNextRun, + updatedAt: nowIso, + }, + runLogId, + }); + + log.info( + `[Automations] Catch-up trigger for "${automation.name}" after ${mode} reconciliation — next run: ${recalculatedNextRun}` + ); + } + }); + + await this.dispatchTriggers(triggers, 'Catch-up trigger callback failed'); + } finally { + this.reconciling = false; + } + } + + private async dispatchTriggers( + triggers: Array<{ automation: Automation; runLogId: string }>, + errorContext: string + ): Promise { + for (const { automation, runLogId } of triggers) { + for (const cb of this.triggerCallbacks) { + try { + cb(automation, runLogId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error(`[Automations] ${errorContext} for ${automation.id}:`, err); + await this.failRunDispatch(runLogId, automation.id, message); + } + } + } + } + + private async failRunDispatch( + runLogId: string, + automationId: string, + errorMessage: string + ): Promise { + await this.updateRunLog( + runLogId, + { + status: 'failure', + error: errorMessage, + finishedAt: new Date().toISOString(), + }, + automationId + ); + await this.setLastRunResult(automationId, 'failure', errorMessage); + } +} + +export const automationsService = new AutomationsService(); diff --git a/src/main/services/ChangelogService.ts b/src/main/services/ChangelogService.ts new file mode 100644 index 0000000000..4205821179 --- /dev/null +++ b/src/main/services/ChangelogService.ts @@ -0,0 +1,545 @@ +import { + compareChangelogVersions, + EMDASH_CHANGELOG_API_URL, + EMDASH_CHANGELOG_URL, + normalizeChangelogVersion, + type ChangelogEntry, +} from '@shared/changelog'; +import { log } from '../lib/logger'; + +const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/generalaction/emdash/releases'; + +type ChangelogCandidate = { + version?: string | null; + title?: string | null; + summary?: string | null; + content?: string | null; + contentHtml?: string | null; + markdown?: string | null; + body?: string | null; + html?: string | null; + publishedAt?: string | null; + published_at?: string | null; + date?: string | null; + url?: string | null; + href?: string | null; + image?: string | null; + image_url?: string | null; + screenshot?: string | null; +}; + +function firstString(...values: Array): string | undefined { + for (const value of values) { + if (typeof value !== 'string') continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +const MONTH_NAME_PATTERN = + '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)'; +const HUMAN_DATE_REGEX = new RegExp(`\\b(${MONTH_NAME_PATTERN}\\s+\\d{1,2},\\s+\\d{4})\\b`, 'i'); +const ISO_DATE_REGEX = /\b(\d{4}-\d{2}-\d{2}(?:[tT][0-9:.+-Z]*)?)\b/; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function extractPublishedAtFromText(value: string): string | undefined { + const normalized = stripTags(value).replace(/\s+/g, ' ').trim(); + if (!normalized) return undefined; + + const humanDate = normalized.match(HUMAN_DATE_REGEX)?.[1]; + if (humanDate) return humanDate; + + const isoDate = normalized.match(ISO_DATE_REGEX)?.[1]; + if (isoDate) return isoDate; + + return undefined; +} + +function extractPublishedAtForVersion(value: string, version?: string): string | undefined { + if (!version) return extractPublishedAtFromText(value); + + const normalized = stripTags(value).replace(/\s+/g, ' ').trim(); + if (!normalized) return undefined; + + const escapedVersion = escapeRegex(version); + const leadingDate = normalized.match( + new RegExp(`(${MONTH_NAME_PATTERN}\\s+\\d{1,2},\\s+\\d{4})\\s+v?${escapedVersion}\\b`, 'i') + )?.[1]; + if (leadingDate) return leadingDate; + + const trailingDate = normalized.match( + new RegExp(`v?${escapedVersion}\\b\\s+(${MONTH_NAME_PATTERN}\\s+\\d{1,2},\\s+\\d{4})`, 'i') + )?.[1]; + if (trailingDate) return trailingDate; + + const leadingIsoDate = normalized.match( + new RegExp(`(\\d{4}-\\d{2}-\\d{2}(?:[tT][0-9:.+-Z]*)?)\\s+v?${escapedVersion}\\b`, 'i') + )?.[1]; + if (leadingIsoDate) return leadingIsoDate; + + const trailingIsoDate = normalized.match( + new RegExp(`v?${escapedVersion}\\b\\s+(\\d{4}-\\d{2}-\\d{2}(?:[tT][0-9:.+-Z]*)?)`, 'i') + )?.[1]; + if (trailingIsoDate) return trailingIsoDate; + + return undefined; +} + +function decodeHtmlEntities(input: string): string { + return input + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'"); +} + +function stripTags(input: string): string { + return decodeHtmlEntities(input.replace(/<[^>]+>/g, ' ')) + .replace(/\s+/g, ' ') + .trim(); +} + +function htmlToMarkdown(html: string): string { + const withoutScripts = html + .replace(//gi, '') + .replace(//gi, '') + .replace(//g, ''); + + const withLinks = withoutScripts.replace( + /]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, + (_match, _quote, href: string, text: string) => { + const label = stripTags(text); + return label ? `[${label}](${href.trim()})` : ''; + } + ); + + const withFormatting = withLinks + .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, text: string) => { + const content = stripTags(text); + return content ? `**${content}**` : ''; + }) + .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_match, _tag, text: string) => { + const content = stripTags(text); + return content ? `*${content}*` : ''; + }) + .replace(/]*>([\s\S]*?)<\/code>/gi, (_match, text: string) => { + const content = stripTags(text); + return content ? `\`${content}\`` : ''; + }); + + const withHeadings = withFormatting + .replace(/]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n') + .replace(/]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n') + .replace(/]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n') + .replace(/]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n') + .replace(/]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n') + .replace(/]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n'); + + const withLists = withHeadings + .replace(/]*>([\s\S]*?)<\/li>/gi, '\n- $1') + .replace(/<\/(ul|ol)>/gi, '\n') + .replace(/<(ul|ol)\b[^>]*>/gi, '\n'); + + const withImages = withLists.replace( + /]*\bsrc=(["'])(.*?)\1[^>]*>/gi, + (_match, _quote, src: string) => { + const altMatch = _match.match(/\balt=(["'])(.*?)\1/i); + const alt = altMatch ? altMatch[2] : ''; + return `![${alt}](${src.trim()})`; + } + ); + + const withParagraphs = withImages + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/]*>/gi, '') + .replace(/<\/div>/gi, '\n') + .replace(/]*>/gi, '\n'); + + return decodeHtmlEntities(withParagraphs.replace(/<[^>]+>/g, ' ')) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/ {2,}/g, ' ') + .trim(); +} + +function extractFirstImageUrl(content: string): string | undefined { + const match = content.match(/!\[[^\]]*\]\((\s*https?:\/\/[^)]+)\)/); + return match?.[1]?.trim(); +} + +function extractFirstImageUrlFromHtml(html: string): string | undefined { + const match = html.match(/]*\bsrc=(["'])(https?:\/\/[^"']+)\1/i); + return match?.[2]?.trim(); +} + +function stripImageFromContent(content: string, imageUrl: string): string { + const escaped = escapeRegex(imageUrl); + const stripped = content + .replace(new RegExp(`!\\[[^\\]]*\\]\\(\\s*${escaped}\\s*\\)\\s*\\n?`), '') + .trim(); + return stripped || content; +} + +function stripHtmlImageFromContent(content: string, imageUrl: string): string { + const escaped = escapeRegex(imageUrl); + const stripped = content + .replace(new RegExp(`]*\\bsrc=["']${escaped}["'][^>]*/?>\\s*\\n?`, 'i'), '') + .trim(); + return stripped || content; +} + +function mapGitHubRelease(release: Record): ChangelogCandidate { + return { + version: typeof release.tag_name === 'string' ? release.tag_name : null, + title: typeof release.name === 'string' ? release.name : null, + body: typeof release.body === 'string' ? release.body : null, + published_at: typeof release.published_at === 'string' ? release.published_at : null, + url: typeof release.html_url === 'string' ? release.html_url : null, + }; +} + +function extractSummaryFromContent(content: string): string { + return ( + content + .split(/\n{2,}/) + .map((block) => block.trim()) + .find((block) => block && !block.startsWith('#') && !block.startsWith('- ')) ?? '' + ); +} + +function removeDuplicateTitle(content: string, title: string): string { + const normalizedTitle = title.trim().toLowerCase(); + const lines = content.split('\n'); + + while (lines.length > 0) { + const line = lines[0].trim(); + if (!line) { + lines.shift(); + continue; + } + + const normalizedLine = line + .replace(/^#+\s*/, '') + .trim() + .toLowerCase(); + if (normalizedLine === normalizedTitle) { + lines.shift(); + continue; + } + + break; + } + + return lines.join('\n').trim(); +} + +function normalizeEntry( + candidate: ChangelogCandidate, + requestedVersion?: string +): ChangelogEntry | null { + const version = normalizeChangelogVersion( + firstString(candidate.version, requestedVersion, extractVersion(candidate.title)) + ); + if (!version) return null; + + const title = firstString(candidate.title) ?? `What's new in Emdash v${version}`; + const contentSource = + firstString(candidate.content, candidate.markdown, candidate.body) ?? + (firstString(candidate.contentHtml, candidate.html) + ? htmlToMarkdown(firstString(candidate.contentHtml, candidate.html)!) + : ''); + + const dedupedContent = removeDuplicateTitle(contentSource, title); + const summary = + firstString(candidate.summary) ?? + extractSummaryFromContent(dedupedContent) ?? + `See what changed in Emdash v${version}.`; + + const explicitImage = firstString(candidate.image, candidate.image_url, candidate.screenshot); + const contentImageUrl = !explicitImage + ? (extractFirstImageUrl(contentSource) ?? extractFirstImageUrlFromHtml(contentSource)) + : undefined; + const htmlImageUrl = + !explicitImage && !contentImageUrl + ? firstString(candidate.contentHtml, candidate.html) + ? extractFirstImageUrlFromHtml(firstString(candidate.contentHtml, candidate.html)!) + : undefined + : undefined; + const image = explicitImage ?? contentImageUrl ?? htmlImageUrl; + + const rawContent = dedupedContent || summary || `See what changed in Emdash v${version}.`; + const content = contentImageUrl + ? stripHtmlImageFromContent(stripImageFromContent(rawContent, contentImageUrl), contentImageUrl) + : rawContent; + + return { + version, + title, + summary, + content, + publishedAt: firstString(candidate.publishedAt, candidate.published_at, candidate.date), + url: firstString(candidate.url, candidate.href), + image, + }; +} + +function extractVersion(input: string | null | undefined): string | undefined { + if (typeof input !== 'string') return undefined; + const match = input.match(/\bv?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/); + return normalizeChangelogVersion(match?.[1] ?? null) ?? undefined; +} + +function pickBestCandidate( + candidates: ChangelogEntry[], + requestedVersion?: string +): ChangelogEntry | null { + if (candidates.length === 0) return null; + + const normalizedRequested = normalizeChangelogVersion(requestedVersion); + if (normalizedRequested) { + const exact = candidates.find((candidate) => candidate.version === normalizedRequested); + if (exact) return exact; + } + + return candidates + .slice() + .sort((left, right) => compareChangelogVersions(right.version, left.version))[0]; +} + +function extractCandidatesFromPayload(payload: unknown): ChangelogCandidate[] { + if (!payload || typeof payload !== 'object') return []; + + if (Array.isArray(payload)) { + return payload.filter((item): item is ChangelogCandidate => !!item && typeof item === 'object'); + } + + const record = payload as Record; + const directCandidate = normalizeEntry(record as ChangelogCandidate); + if (directCandidate) return [record as ChangelogCandidate]; + + const collections = ['entry', 'release', 'item', 'entries', 'items', 'releases', 'data']; + for (const key of collections) { + const value = record[key]; + if (Array.isArray(value)) { + return value.filter((item): item is ChangelogCandidate => !!item && typeof item === 'object'); + } + if (value && typeof value === 'object') { + return [value as ChangelogCandidate]; + } + } + + return []; +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', + 'Cache-Control': 'no-cache', + }, + }); + + if (!response.ok) return null; + + const contentType = response.headers.get('content-type') ?? ''; + if (!contentType.includes('json')) return null; + + return response.json(); +} + +async function fetchHtml(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'text/html,application/xhtml+xml', 'Cache-Control': 'no-cache' }, + }); + + if (!response.ok) return null; + return response.text(); +} + +function extractTime(block: string): string | undefined { + const datetime = block.match(/]*datetime=(["'])(.*?)\1/i)?.[2]; + if (datetime?.trim()) return datetime.trim(); + + const timeContent = block.match(/]*>([\s\S]*?)<\/time>/i)?.[1]; + const normalized = stripTags(timeContent ?? ''); + return normalized || undefined; +} + +function extractTitle(block: string): string | undefined { + const heading = block.match(/]*>([\s\S]*?)<\/h[1-6]>/i)?.[1]; + const title = stripTags(heading ?? ''); + return title || undefined; +} + +function extractSummary(block: string): string | undefined { + const paragraph = block.match(/]*>([\s\S]*?)<\/p>/i)?.[1]; + const summary = stripTags(paragraph ?? ''); + return summary || undefined; +} + +function withResolvedHtmlPublishedAt( + entry: ChangelogEntry | null, + html: string, + requestedVersion?: string +): ChangelogEntry | null { + if (!entry) return null; + if (entry.publishedAt) return entry; + + const publishedAt = extractPublishedAtForVersion(html, requestedVersion ?? entry.version); + return publishedAt ? { ...entry, publishedAt } : entry; +} + +export function parseChangelogHtml(html: string, requestedVersion?: string): ChangelogEntry | null { + const blocks = html.match(/<(article|section)\b[\s\S]*?<\/\1>/gi) ?? []; + const candidates: ChangelogEntry[] = []; + + for (const block of blocks) { + const versionFromBlock = normalizeChangelogVersion( + block.match(/data-version=(["'])(.*?)\1/i)?.[2] ?? + extractVersion(block) ?? + requestedVersion ?? + null + ); + if (!versionFromBlock) continue; + + const candidate = normalizeEntry( + { + version: versionFromBlock, + title: extractTitle(block), + summary: extractSummary(block), + contentHtml: block, + publishedAt: extractTime(block) ?? extractPublishedAtForVersion(block, versionFromBlock), + }, + requestedVersion + ); + + if (candidate) { + candidates.push(candidate); + } + } + + if (candidates.length > 0) { + return withResolvedHtmlPublishedAt( + pickBestCandidate(candidates, requestedVersion), + html, + requestedVersion + ); + } + + return withResolvedHtmlPublishedAt( + normalizeEntry( + { + version: normalizeChangelogVersion(requestedVersion) ?? undefined, + title: extractTitle(html), + summary: extractSummary(html), + contentHtml: html, + publishedAt: + extractTime(html) ?? + extractPublishedAtForVersion( + html, + normalizeChangelogVersion(requestedVersion) ?? undefined + ), + }, + requestedVersion + ), + html, + requestedVersion + ); +} + +class ChangelogService { + private async getHtmlEntry(requestedVersion?: string): Promise { + try { + const html = await fetchHtml(EMDASH_CHANGELOG_URL); + if (!html) return null; + return parseChangelogHtml(html, requestedVersion); + } catch (error) { + log.error('Failed to fetch changelog HTML', error); + return null; + } + } + + private async getGitHubReleaseEntry(requestedVersion?: string): Promise { + try { + const version = normalizeChangelogVersion(requestedVersion); + const url = version + ? `${GITHUB_RELEASES_API_URL}/tags/v${version}` + : `${GITHUB_RELEASES_API_URL}/latest`; + + const payload = await fetchJson(url); + if (!payload || typeof payload !== 'object') return null; + + return normalizeEntry( + mapGitHubRelease(payload as Record), + version ?? undefined + ); + } catch (error) { + log.debug('GitHub releases fetch failed', { error }); + return null; + } + } + + private async supplementWithGitHubImage(entry: ChangelogEntry): Promise { + if (entry.image) return entry; + + const ghEntry = await this.getGitHubReleaseEntry(entry.version); + if (ghEntry?.image) return { ...entry, image: ghEntry.image }; + + return entry; + } + + async getLatestEntry(requestedVersion?: string): Promise { + const version = normalizeChangelogVersion(requestedVersion); + const apiUrls = [ + version + ? `${EMDASH_CHANGELOG_API_URL}?version=${encodeURIComponent(version)}` + : `${EMDASH_CHANGELOG_API_URL}?latest=1`, + version + ? `${EMDASH_CHANGELOG_URL}.json?version=${encodeURIComponent(version)}` + : `${EMDASH_CHANGELOG_URL}.json`, + ]; + + for (const url of apiUrls) { + try { + const payload = await fetchJson(url); + if (!payload) continue; + + const entries = extractCandidatesFromPayload(payload) + .map((candidate) => normalizeEntry(candidate, version ?? undefined)) + .filter((candidate): candidate is ChangelogEntry => candidate !== null); + const match = pickBestCandidate(entries, version ?? undefined); + if (match) { + let result = match; + if (!match.publishedAt) { + const htmlEntry = await this.getHtmlEntry(match.version); + if (htmlEntry) { + result = { + ...match, + publishedAt: htmlEntry.publishedAt ?? match.publishedAt, + url: match.url ?? htmlEntry.url, + }; + } + } + return this.supplementWithGitHubImage(result); + } + } catch (error) { + log.debug('Changelog JSON fetch failed', { url, error }); + } + } + + const htmlEntry = await this.getHtmlEntry(version ?? undefined); + if (htmlEntry) return this.supplementWithGitHubImage(htmlEntry); + + return this.getGitHubReleaseEntry(version ?? undefined); + } +} + +export const changelogService = new ChangelogService(); diff --git a/src/main/services/CodexSessionService.ts b/src/main/services/CodexSessionService.ts new file mode 100644 index 0000000000..37b956b080 --- /dev/null +++ b/src/main/services/CodexSessionService.ts @@ -0,0 +1,187 @@ +import os from 'os'; +import path from 'path'; +import type sqlite3Type from 'sqlite3'; +import { log } from '../lib/logger'; + +export type CodexThread = { + id: string; + cwd: string; + createdAt: number; + updatedAt: number; + archived: boolean; +}; + +let codexStatePathOverride: string | null = null; + +export function _setCodexStatePathForTest(nextPath: string | null): void { + codexStatePathOverride = nextPath; +} + +function resolveCodexStatePath(): string { + return codexStatePathOverride || path.join(os.homedir(), '.codex', 'state_5.sqlite'); +} + +class CodexSessionService { + private sqliteModulePromise: Promise | null = null; + + private async loadSqliteModule(): Promise { + if (!this.sqliteModulePromise) { + this.sqliteModulePromise = import('sqlite3').then( + (mod) => mod as unknown as typeof sqlite3Type + ); + } + return this.sqliteModulePromise; + } + + private async openDatabase(): Promise { + const sqliteModule = await this.loadSqliteModule(); + const dbPath = resolveCodexStatePath(); + + return await new Promise((resolve, reject) => { + const db = new sqliteModule.Database(dbPath, sqliteModule.OPEN_READONLY, (err) => { + if (err) { + reject(err); + return; + } + if (typeof db.configure === 'function') { + db.configure('busyTimeout', 2_000); + } + resolve(db); + }); + }); + } + + private async closeDatabase(db: sqlite3Type.Database): Promise { + await new Promise((resolve) => { + db.close(() => resolve()); + }); + } + + private async all>( + sql: string, + params: unknown[] + ): Promise { + const db = await this.openDatabase(); + try { + return await new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) { + reject(err); + return; + } + resolve((rows as T[]) ?? []); + }); + }); + } finally { + await this.closeDatabase(db); + } + } + + private async get>( + sql: string, + params: unknown[] + ): Promise { + const db = await this.openDatabase(); + try { + return await new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) { + reject(err); + return; + } + resolve((row as T | undefined) ?? null); + }); + }); + } finally { + await this.closeDatabase(db); + } + } + + private mapThreadRow(row: Record | null): CodexThread | null { + if (!row) return null; + if (typeof row.id !== 'string' || typeof row.cwd !== 'string') { + return null; + } + return { + id: row.id, + cwd: row.cwd, + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + archived: Boolean(row.archived ?? 0), + }; + } + + async findThreadById(threadId: string): Promise { + try { + const row = await this.get>( + 'SELECT id, cwd, created_at, updated_at, archived FROM threads WHERE id = ? LIMIT 1', + [threadId] + ); + return this.mapThreadRow(row); + } catch (error) { + log.warn('CodexSessionService: failed to load thread by id', { + threadId, + error: String(error), + }); + return null; + } + } + + async threadExistsForCwd(threadId: string, cwd: string): Promise { + const thread = await this.findThreadById(threadId); + return !!thread && !thread.archived && thread.cwd === cwd; + } + + async findRecentThreadsForCwd(cwd: string, sinceMs: number): Promise { + const sinceSeconds = Math.max(0, Math.floor(sinceMs / 1000)); + try { + const rows = await this.all>( + `SELECT id, cwd, created_at, updated_at, archived + FROM threads + WHERE cwd = ? + AND archived = 0 + AND (updated_at >= ? OR created_at >= ?) + ORDER BY updated_at DESC, created_at DESC`, + [cwd, sinceSeconds, sinceSeconds] + ); + return rows.map((row) => this.mapThreadRow(row)).filter((row): row is CodexThread => !!row); + } catch (error) { + log.warn('CodexSessionService: failed to load recent threads for cwd', { + cwd, + sinceMs, + sinceSeconds, + dbPath: resolveCodexStatePath(), + error: String(error), + }); + return []; + } + } + + async findLatestThreadForCwd(cwd: string): Promise { + try { + const row = await this.get>( + `SELECT id, cwd, created_at, updated_at, archived + FROM threads + WHERE cwd = ? + ORDER BY updated_at DESC, created_at DESC + LIMIT 1`, + [cwd] + ); + return this.mapThreadRow(row); + } catch (error) { + log.warn('CodexSessionService: failed to load latest thread for cwd', { + cwd, + dbPath: resolveCodexStatePath(), + error: String(error), + }); + return null; + } + } + + async findLatestRecentThreadForCwd(cwd: string, sinceMs: number): Promise { + const threads = await this.findRecentThreadsForCwd(cwd, sinceMs); + return threads[0] ?? null; + } +} + +export const codexSessionService = new CodexSessionService(); diff --git a/src/main/services/ConnectionsService.ts b/src/main/services/ConnectionsService.ts index 40c001e56c..6965d16aab 100644 --- a/src/main/services/ConnectionsService.ts +++ b/src/main/services/ConnectionsService.ts @@ -258,7 +258,11 @@ class ConnectionsService { }; } - return { ...result, command }; + // Never cache the shell binary path as the provider path. + // If provider resolution still fails here, keep `resolvedPath` null so + // PTY startup falls back to shell-based spawn instead of direct-spawning the shell. + const providerResolvedPath = this.resolveCommandPath(command); + return { ...result, command, resolvedPath: providerResolvedPath }; } private async runCommand( diff --git a/src/main/services/DatabaseService.ts b/src/main/services/DatabaseService.ts index 53a15799fe..75a2ee27dc 100644 --- a/src/main/services/DatabaseService.ts +++ b/src/main/services/DatabaseService.ts @@ -1,5 +1,5 @@ import type sqlite3Type from 'sqlite3'; -import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm'; import { readMigrationFiles } from 'drizzle-orm/migrator'; import { resolveDatabasePath, resolveMigrationsPath } from '../db/path'; import { getDrizzleClient } from '../db/drizzleClient'; @@ -9,14 +9,11 @@ import { tasks as tasksTable, conversations as conversationsTable, messages as messagesTable, - lineComments as lineCommentsTable, sshConnections as sshConnectionsTable, type ProjectRow, type TaskRow, type ConversationRow, type MessageRow, - type LineCommentRow, - type LineCommentInsert, type SshConnectionRow, type SshConnectionInsert, } from '../db/schema'; @@ -100,6 +97,27 @@ export class DatabaseSchemaMismatchError extends Error { } } +export class ProjectConflictError extends Error { + readonly code = 'PROJECT_CONFLICT'; + readonly existingProjectId: string; + readonly existingProjectName: string; + readonly projectPath: string; + + constructor(args: { + existingProjectId: string; + existingProjectName: string; + projectPath: string; + }) { + super( + `A project already exists for "${args.existingProjectName}" at ${args.projectPath}. Emdash kept the existing project and its tasks unchanged.` + ); + this.name = 'ProjectConflictError'; + this.existingProjectId = args.existingProjectId; + this.existingProjectName = args.existingProjectName; + this.projectPath = args.projectPath; + } +} + export class DatabaseService { private static migrationsApplied = false; private db: sqlite3Type.Database | null = null; @@ -175,49 +193,56 @@ export class DatabaseService { ); const githubRepository = project.githubInfo?.repository ?? null; const githubConnected = project.githubInfo?.connected ? 1 : 0; + const existingByIdRows = await db + .select({ + id: projectsTable.id, + path: projectsTable.path, + }) + .from(projectsTable) + .where(eq(projectsTable.id, project.id)) + .limit(1); + const existingById = existingByIdRows[0] ?? null; - // Clean up stale rows that would conflict on id or path but not both. - // This prevents unique constraint errors when re-adding a deleted project. - await db - .delete(projectsTable) - .where( - or( - and(eq(projectsTable.id, project.id), ne(projectsTable.path, project.path)), - and(eq(projectsTable.path, project.path), ne(projectsTable.id, project.id)) - ) - ); - - await db - .insert(projectsTable) - .values({ - id: project.id, - name: project.name, - path: project.path, - gitRemote, - gitBranch, - baseRef: baseRef ?? null, - githubRepository, - githubConnected, - sshConnectionId: project.sshConnectionId ?? null, - isRemote: project.isRemote ? 1 : 0, - remotePath: project.remotePath ?? null, - updatedAt: sql`CURRENT_TIMESTAMP`, + const existingByPathRows = await db + .select({ + id: projectsTable.id, + name: projectsTable.name, + path: projectsTable.path, }) - .onConflictDoUpdate({ - target: projectsTable.path, - set: { - name: project.name, - gitRemote, - gitBranch, - baseRef: baseRef ?? null, - githubRepository, - githubConnected, - sshConnectionId: project.sshConnectionId ?? null, - isRemote: project.isRemote ? 1 : 0, - remotePath: project.remotePath ?? null, - updatedAt: sql`CURRENT_TIMESTAMP`, - }, + .from(projectsTable) + .where(eq(projectsTable.path, project.path)) + .limit(1); + const existingByPath = existingByPathRows[0] ?? null; + + if (existingByPath && existingByPath.id !== project.id) { + throw new ProjectConflictError({ + existingProjectId: existingByPath.id, + existingProjectName: existingByPath.name, + projectPath: existingByPath.path, }); + } + + const values = { + id: project.id, + name: project.name, + path: project.path, + gitRemote, + gitBranch, + baseRef: baseRef ?? null, + githubRepository, + githubConnected, + sshConnectionId: project.sshConnectionId ?? null, + isRemote: project.isRemote ? 1 : 0, + remotePath: project.remotePath ?? null, + updatedAt: sql`CURRENT_TIMESTAMP`, + } as const; + + if (existingById) { + await db.update(projectsTable).set(values).where(eq(projectsTable.id, project.id)); + return; + } + + await db.insert(projectsTable).values(values); } async getProjects(): Promise { @@ -462,12 +487,13 @@ export class DatabaseService { return rows.map((row) => this.mapDrizzleConversationRow(row)); } - async getOrCreateDefaultConversation(taskId: string): Promise { + async getOrCreateDefaultConversation(taskId: string, provider?: string): Promise { if (this.disabled) { return { id: `conv-${taskId}-default`, taskId, title: 'Default Conversation', + provider: provider ?? null, isMain: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -483,7 +509,16 @@ export class DatabaseService { .limit(1); if (existingRows.length > 0) { - return this.mapDrizzleConversationRow(existingRows[0]); + const existing = existingRows[0]; + // Backfill provider if it was previously saved as null + if (!existing.provider && provider) { + await db + .update(conversationsTable) + .set({ provider, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where(eq(conversationsTable.id, existing.id)); + return this.mapDrizzleConversationRow({ ...existing, provider }); + } + return this.mapDrizzleConversationRow(existing); } const conversationId = `conv-${taskId}-${Date.now()}`; @@ -491,6 +526,7 @@ export class DatabaseService { id: conversationId, taskId, title: 'Default Conversation', + provider: provider ?? null, isMain: true, isActive: true, }); @@ -509,6 +545,7 @@ export class DatabaseService { id: conversationId, taskId, title: 'Default Conversation', + provider: provider ?? null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -568,7 +605,8 @@ export class DatabaseService { taskId: string, title: string, provider?: string, - isMain?: boolean + isMain?: boolean, + metadata?: string | null ): Promise { if (this.disabled) { return { @@ -623,6 +661,7 @@ export class DatabaseService { isActive: true, isMain: isMain ?? false, displayOrder: maxOrder + 1, + metadata: metadata ?? null, }; await this.saveConversation(newConversation); @@ -693,87 +732,6 @@ export class DatabaseService { .where(eq(conversationsTable.id, conversationId)); } - // Line comment management methods - async saveLineComment( - input: Omit - ): Promise { - if (this.disabled) return ''; - const id = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const { db } = await getDrizzleClient(); - await db.insert(lineCommentsTable).values({ - id, - taskId: input.taskId, - filePath: input.filePath, - lineNumber: input.lineNumber, - lineContent: input.lineContent ?? null, - content: input.content, - updatedAt: sql`CURRENT_TIMESTAMP`, - }); - return id; - } - - async getLineComments(taskId: string, filePath?: string): Promise { - if (this.disabled) return []; - const { db } = await getDrizzleClient(); - - if (filePath) { - const rows = await db - .select() - .from(lineCommentsTable) - .where( - sql`${lineCommentsTable.taskId} = ${taskId} AND ${lineCommentsTable.filePath} = ${filePath}` - ) - .orderBy(asc(lineCommentsTable.lineNumber)); - return rows; - } - - const rows = await db - .select() - .from(lineCommentsTable) - .where(eq(lineCommentsTable.taskId, taskId)) - .orderBy(asc(lineCommentsTable.lineNumber)); - return rows; - } - - async updateLineComment(id: string, content: string): Promise { - if (this.disabled) return; - const { db } = await getDrizzleClient(); - await db - .update(lineCommentsTable) - .set({ - content, - updatedAt: sql`CURRENT_TIMESTAMP`, - }) - .where(eq(lineCommentsTable.id, id)); - } - - async deleteLineComment(id: string): Promise { - if (this.disabled) return; - const { db } = await getDrizzleClient(); - await db.delete(lineCommentsTable).where(eq(lineCommentsTable.id, id)); - } - - async markCommentsSent(commentIds: string[]): Promise { - if (this.disabled || commentIds.length === 0) return; - const { db } = await getDrizzleClient(); - const now = new Date().toISOString(); - await db - .update(lineCommentsTable) - .set({ sentAt: now }) - .where(inArray(lineCommentsTable.id, commentIds)); - } - - async getUnsentComments(taskId: string): Promise { - if (this.disabled) return []; - const { db } = await getDrizzleClient(); - const rows = await db - .select() - .from(lineCommentsTable) - .where(and(eq(lineCommentsTable.taskId, taskId), isNull(lineCommentsTable.sentAt))) - .orderBy(asc(lineCommentsTable.filePath), asc(lineCommentsTable.lineNumber)); - return rows; - } - // SSH connection management methods async saveSshConnection( connection: Omit & { id?: string } diff --git a/src/main/services/EmdashAccountService.ts b/src/main/services/EmdashAccountService.ts new file mode 100644 index 0000000000..7fb35c0fd1 --- /dev/null +++ b/src/main/services/EmdashAccountService.ts @@ -0,0 +1,149 @@ +import { net } from 'electron'; +import { GITHUB_CONFIG } from '../config/github.config'; +import { log } from '../lib/logger'; +import { accountCredentialStore } from './AccountCredentialStore'; +import { accountProfileCache } from './AccountProfileCache'; +import type { CachedProfile } from './AccountProfileCache'; +import { oauthFlowService } from './OAuthFlowService'; +import type { AccountUser, ExchangeResult } from './OAuthFlowService'; + +export type { AccountUser, ExchangeResult }; + +interface SignInResult { + accessToken: string; + providerId: string; + user: AccountUser; +} + +interface SessionState { + user: AccountUser | null; + isSignedIn: boolean; + hasAccount: boolean; +} + +export class EmdashAccountService { + private cachedProfile: CachedProfile | null = null; + private sessionToken: string | null = null; + private initialized = false; + + /** + * Load cached profile from disk on first access. + */ + private ensureInitialized(): void { + if (this.initialized) return; + this.initialized = true; + this.cachedProfile = accountProfileCache.read(); + } + + /** + * Get current session state synchronously (uses cached data). + */ + getSession(): SessionState { + this.ensureInitialized(); + const hasAccount = this.cachedProfile?.hasAccount === true; + const isSignedIn = hasAccount && this.sessionToken !== null; + return { + user: + isSignedIn && this.cachedProfile + ? { + userId: this.cachedProfile.userId, + username: this.cachedProfile.username, + avatarUrl: this.cachedProfile.avatarUrl, + email: this.cachedProfile.email, + } + : null, + isSignedIn, + hasAccount, + }; + } + + /** + * Initialize session token from keychain (call on app startup). + */ + async loadSessionToken(): Promise { + this.sessionToken = await accountCredentialStore.get(); + } + + /** + * Quick health check — returns true if the auth server responds within 3s. + */ + async checkServerHealth(): Promise { + const { baseUrl } = GITHUB_CONFIG.oauthServer; + try { + const response = await net.fetch(`${baseUrl}/health`, { + signal: AbortSignal.timeout(3000), + }); + return response.ok; + } catch { + return false; + } + } + + /** + * Validate session against the auth server. Returns true if session is valid. + */ + async validateSession(): Promise { + const token = this.sessionToken; + if (!token) return false; + + const { baseUrl } = GITHUB_CONFIG.oauthServer; + try { + const response = await net.fetch(`${baseUrl}/api/auth/get-session`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(3000), + }); + + if (!response.ok) { + this.sessionToken = null; + await accountCredentialStore.clear(); + if (this.cachedProfile) { + this.cachedProfile.hasAccount = true; + accountProfileCache.write(this.cachedProfile); + } + return false; + } + + if (this.cachedProfile) { + this.cachedProfile.lastValidated = new Date().toISOString(); + accountProfileCache.write(this.cachedProfile); + } + return true; + } catch { + return this.sessionToken !== null; + } + } + + /** + * Run the OAuth sign-in flow, store tokens, and update profile cache. + */ + async signIn(): Promise { + const result = await oauthFlowService.startFlow(); + + await accountCredentialStore.set(result.sessionToken); + this.sessionToken = result.sessionToken; + + const profile: CachedProfile = { + hasAccount: true, + userId: result.user.userId, + username: result.user.username, + avatarUrl: result.user.avatarUrl, + email: result.user.email, + lastValidated: new Date().toISOString(), + }; + this.cachedProfile = profile; + accountProfileCache.write(profile); + + return { accessToken: result.accessToken, providerId: result.providerId, user: result.user }; + } + + /** + * Sign out — clear session token but keep hasAccount: true so we can + * prompt the user to re-sign-in later. + */ + async signOut(): Promise { + this.sessionToken = null; + await accountCredentialStore.clear(); + } +} + +export const emdashAccountService = new EmdashAccountService(); diff --git a/src/main/services/ForgejoService.ts b/src/main/services/ForgejoService.ts new file mode 100644 index 0000000000..b0bc2ee17b --- /dev/null +++ b/src/main/services/ForgejoService.ts @@ -0,0 +1,302 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; +import { promisify } from 'util'; +import { exec } from 'child_process'; +import { log } from '../lib/logger'; +import type { ForgejoIssueSummary } from '@shared/forgejo/types'; + +const execAsync = promisify(exec); + +type ForgejoCreds = { + siteUrl: string; +}; + +export class ForgejoService { + private readonly SERVICE_NAME = 'emdash-forgejo'; + private readonly ACCOUNT_NAME = 'forgejo-token'; + private readonly CONF_FILE = join(app.getPath('userData'), 'forgejo.json'); + + async saveCredentials( + instanceUrl: string, + token: string + ): Promise<{ success: boolean; error?: string }> { + try { + instanceUrl = instanceUrl.trim(); + token = token.trim(); + if (instanceUrl.length == 0 || token.length == 0) { + return { success: false, error: 'Instance URL and token are required' }; + } + try { + const parsedUrl = new URL(instanceUrl); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + return { success: false, error: 'Invalid URL format' }; + } + } catch { + return { success: false, error: 'Invalid URL format' }; + } + if (instanceUrl[instanceUrl.length - 1] == '/') { + instanceUrl = instanceUrl.substring(0, instanceUrl.length - 1); + } + + const keytar = await import('keytar'); + await keytar.setPassword(this.SERVICE_NAME, this.ACCOUNT_NAME, token); + this.writeCreds({ siteUrl: instanceUrl }); + return { success: true }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + + async clearCredentials(): Promise<{ success: boolean; error?: string }> { + try { + const keytar = await import('keytar'); + await keytar.deletePassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + if (existsSync(this.CONF_FILE)) { + unlinkSync(this.CONF_FILE); + } + return { success: true }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + + async checkConnection(): Promise<{ success: boolean; error?: string }> { + try { + const { siteUrl, token } = await this.requireAuth(); + const user = await this.getUserInfo(siteUrl, token); + if (!user.success) { + return { success: false, error: user.error }; + } + return { success: true }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + + async initialFetch( + projectPath?: string, + limit: number = 10 + ): Promise<{ success: boolean; issues?: ForgejoIssueSummary[]; error?: string }> { + try { + const { siteUrl, token } = await this.requireAuth(); + if (!siteUrl || !token) { + return { success: false, error: 'Forgejo is not configured' }; + } + if (!projectPath) { + return { success: false, error: 'Project path is required' }; + } + const { success, ownerRepo, error } = await this.resolveOwnerRepo(projectPath); + if (!success || !ownerRepo) { + return { success: false, error: error }; + } + const issues = await this.fetchIssues(ownerRepo, limit); + return { success: true, issues }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + + async searchIssues( + projectPath: string | undefined, + searchTerm: string, + limit: number = 10 + ): Promise<{ success: boolean; issues?: ForgejoIssueSummary[]; error?: string }> { + try { + if (!searchTerm || !searchTerm.trim()) { + return { success: true, issues: [] }; + } + const { siteUrl, token } = await this.requireAuth(); + if (!siteUrl || !token) { + return { success: false, error: 'Forgejo is not configured' }; + } + if (!projectPath) { + return { success: false, error: 'Project path is required' }; + } + const { success, ownerRepo, error } = await this.resolveOwnerRepo(projectPath); + if (!success || !ownerRepo) { + return { success: false, error }; + } + const url = new URL(`${siteUrl}/api/v1/repos/${ownerRepo}/issues`); + url.searchParams.set('state', 'open'); + url.searchParams.set('type', 'issues'); + url.searchParams.set('q', searchTerm.trim()); + url.searchParams.set('limit', String(limit)); + const response = await this.doRequest(url, token, 'GET'); + if (!response.ok) { + return { success: false, error: 'Failed to search Forgejo issues' }; + } + const data = (await response.json()) as any[]; + return { success: true, issues: this.normalizeIssues(data) }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + + private async resolveOwnerRepo( + projectPath: string + ): Promise<{ success: boolean; ownerRepo?: string; error?: string }> { + try { + const { siteUrl } = await this.requireAuth(); + const instanceHost = new URL(siteUrl).hostname.toLowerCase(); + + const { stdout } = await this.execCmd('git remote get-url origin', { cwd: projectPath }); + const remoteUrl = stdout.trim(); + if (!remoteUrl) { + return { success: false, error: 'No remote URL found for origin' }; + } + + let remoteHost: string | undefined; + let slug: string | undefined; + + if (remoteUrl.startsWith('git@')) { + // SSH: git@forgejo.example.com:owner/repo.git + const hostMatch = remoteUrl.match(/^git@([^:]+):/); + if (hostMatch) { + remoteHost = hostMatch[1].toLowerCase(); + } + const slugMatch = remoteUrl.match(/:(.*?)(\.git)?$/); + if (slugMatch && slugMatch[1]) { + slug = slugMatch[1]; + } + } else if (remoteUrl.startsWith('https://') || remoteUrl.startsWith('http://')) { + // HTTPS: https:///owner/repo.git + const parsed = new URL(remoteUrl); + remoteHost = parsed.hostname.toLowerCase(); + slug = parsed.pathname.replace(/^\//, '').replace(/\.git$/, ''); + } + + if (remoteHost && remoteHost !== instanceHost) { + return { + success: false, + error: `Git remote host "${remoteHost}" does not match configured Forgejo instance "${instanceHost}". Check your Forgejo settings.`, + }; + } + + if (!slug) { + return { success: false, error: 'Unable to extract owner/repo from remote URL' }; + } + + return { success: true, ownerRepo: slug.trim() }; + } catch (e: any) { + return { success: false, error: 'Unable to resolve repository from remote URL' }; + } + } + + private async fetchIssues(ownerRepo: string, limit: number = 10): Promise { + const { siteUrl, token } = await this.requireAuth(); + if (!siteUrl || !token) { + throw new Error('Forgejo is not configured'); + } + const url = new URL(`${siteUrl}/api/v1/repos/${ownerRepo}/issues`); + url.searchParams.set('state', 'open'); + url.searchParams.set('type', 'issues'); + url.searchParams.set('limit', String(limit)); + const response = await this.doRequest(url, token, 'GET'); + if (!response.ok) { + throw new Error('Could not fetch issues'); + } + const data = (await response.json()) as any[]; + return this.normalizeIssues(data); + } + + private normalizeIssues(issues: any[]): ForgejoIssueSummary[] { + return issues.map((issue) => ({ + id: issue.id, + number: issue.number, + title: issue.title, + description: issue.body ?? null, + html_url: issue.html_url ?? null, + state: issue.state ?? null, + assignee: issue.assignee + ? { name: issue.assignee.full_name || issue.assignee.login, login: issue.assignee.login } + : null, + labels: Array.isArray(issue.labels) ? issue.labels.map((l: any) => l.name) : null, + updated_at: issue.updated ?? issue.updated_at ?? null, + })); + } + + private async execCmd(cmd: string, options?: any): Promise<{ stdout: string; stderr: string }> { + try { + const result = await execAsync(cmd, { encoding: 'utf8', ...options }); + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; + } catch (e: any) { + throw e; + } + } + + private async doRequest( + url: URL, + token: string, + method: 'GET' | 'POST', + payload?: string, + extraHeaders?: Record + ): Promise { + return fetch(url.toString(), { + method, + headers: { + Authorization: `token ${token}`, + 'Content-Type': 'application/json', + ...(extraHeaders || {}), + }, + body: method === 'POST' ? payload : undefined, + }); + } + + private async requireAuth(): Promise<{ siteUrl: string; token: string }> { + try { + const creds = this.readCreds(); + if (!creds) { + throw new Error('Invalid credential files'); + } + const keytar = await import('keytar'); + const token = await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + if (!token) { + throw new Error('Token not set'); + } + return { siteUrl: creds.siteUrl, token: token }; + } catch (e: any) { + throw new Error(e?.message); + } + } + + private async getUserInfo( + siteUrl: string, + token: string + ): Promise<{ success: boolean; error?: string; user?: any }> { + try { + const url = new URL(`${siteUrl}/api/v1/user`); + const response = await this.doRequest(url, token, 'GET'); + if (!response.ok) { + return { success: false, error: 'Failed to get user info' }; + } + const user = await response.json(); + return { success: true, user }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + + private writeCreds(creds: ForgejoCreds): void { + const { siteUrl } = creds; + const obj: any = { siteUrl }; + writeFileSync(this.CONF_FILE, JSON.stringify(obj), 'utf8'); + } + + private readCreds(): ForgejoCreds | null { + try { + if (!existsSync(this.CONF_FILE)) return null; + const raw = readFileSync(this.CONF_FILE, 'utf8'); + const obj = JSON.parse(raw); + return { siteUrl: obj.siteUrl }; + } catch (error) { + log.error('Failed to read Forgejo credentials:', error); + return null; + } + } +} + +export const forgejoService = new ForgejoService(); diff --git a/src/main/services/GitHubService.ts b/src/main/services/GitHubService.ts index 33a87dbe84..c7d290d58f 100644 --- a/src/main/services/GitHubService.ts +++ b/src/main/services/GitHubService.ts @@ -1,4 +1,4 @@ -import { exec, spawn } from 'child_process'; +import { exec } from 'child_process'; import { promisify } from 'util'; import * as path from 'path'; import * as fs from 'fs'; @@ -6,6 +6,7 @@ import { GITHUB_CONFIG } from '../config/github.config'; import { getMainWindow } from '../app/window'; import { errorTracking } from '../errorTracking'; import { sortByUpdatedAtDesc } from '../utils/issueSorting'; +import { quoteShellArg } from '../utils/shellEscape'; const execAsync = promisify(exec); @@ -33,6 +34,11 @@ export interface GitHubRepo { forks_count: number; } +export interface GitHubReviewer { + login: string; + state?: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'; +} + export interface GitHubPullRequest { number: number; title: string; @@ -54,6 +60,21 @@ export interface GitHubPullRequest { nameWithOwner?: string; url?: string; } | null; + reviewDecision?: string | null; + reviewers?: GitHubReviewer[]; + additions?: number; + deletions?: number; + checksStatus?: 'pass' | 'fail' | 'pending' | 'none'; +} + +export interface GitHubPullRequestListResult { + prs: GitHubPullRequest[]; + totalCount: number; +} + +export interface GitHubPullRequestListOptions { + limit?: number; + searchQuery?: string; } export interface AuthResult { @@ -76,6 +97,7 @@ export interface DeviceCodeResult { export class GitHubService { private readonly SERVICE_NAME = 'emdash-github'; private readonly ACCOUNT_NAME = 'github-token'; + private readonly MIGRATION_BLOCK_ACCOUNT = 'github-migration-blocked'; // Polling state management private isPolling = false; @@ -83,6 +105,14 @@ export class GitHubService { private currentDeviceCode: string | null = null; private currentInterval = 5; + // One-shot migration guard: try reading from `gh auth token` at most once + // per process when the Emdash keychain is empty. + private migrationAttempted = false; + private migrationInFlight: Promise | null = null; + + // Serializes auth state changes (logout + legacy token migration persistence). + private authStateLock: Promise = Promise.resolve(); + /** * Authenticate with GitHub using Device Flow * Returns device code info for the UI to display to the user @@ -91,6 +121,49 @@ export class GitHubService { return await this.requestDeviceCode(); } + /** + * Store a GitHub token obtained via OAuth (Emdash Accounts flow). + */ + async storeTokenFromOAuth(token: string): Promise { + await this.storeToken(token); + } + + /** + * Start OAuth authentication via Emdash Account. + * Opens browser to auth server, waits for loopback callback, exchanges code for token. + */ + async startOAuthAuth(): Promise { + const { emdashAccountService } = await import('./EmdashAccountService'); + try { + const result = await emdashAccountService.signIn(); + + if (result.providerId === 'github') { + await this.storeToken(result.accessToken); + } + + const user = await this.getUserInfo(result.accessToken); + + if (user?.login) { + await errorTracking.updateGithubUsername(user.login); + } + + const mainWindow = getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('github:auth:success', { + token: result.accessToken, + user, + }); + } + + return { success: true, token: result.accessToken, user: user || undefined }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'OAuth authentication failed', + }; + } + } + /** * Start Device Flow authentication with automatic background polling * Emits events to renderer for UI updates @@ -365,28 +438,16 @@ export class GitHubService { }; if (data.access_token) { - // We get the token, now fetch user info immediately before returning success - // This ensures the UI has the correct username without a race condition + // We get the token, now fetch user info immediately before returning success. const token = data.access_token; const user = await this.getUserInfo(token); - // Store token and authenticate gh CLI BEFORE returning success. - // This must complete synchronously (awaited) so that when the renderer - // receives the success event and checks `gh api user`, the CLI is - // already authenticated. Previously these were deferred via setImmediate, - // causing a race where the status check ran before gh CLI auth finished. try { await this.storeToken(token); } catch (error) { console.warn('Failed to store token:', error); } - try { - await this.authenticateGHCLI(token); - } catch { - // Silent fail - gh CLI might not be installed - } - const mainWindow = getMainWindow(); if (user && mainWindow) { mainWindow.webContents.send('github:auth:user-updated', { @@ -421,71 +482,38 @@ export class GitHubService { } /** - * Authenticate gh CLI with the OAuth token + * Environment for gh invocations. Always scope auth to Emdash's stored token + * so we do not read or mutate the user's global gh login state. */ - private async authenticateGHCLI(token: string): Promise { - try { - // Check if gh CLI is installed first - await execAsync('gh --version'); - - // Security: Authenticate gh CLI with token via stdin (not shell interpolation) - // This prevents command injection if token contains shell metacharacters - await new Promise((resolve, reject) => { - const child = spawn('gh', ['auth', 'login', '--with-token'], { - stdio: ['pipe', 'pipe', 'pipe'], - }); - - child.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`gh auth login failed with code ${code}`)); - } - }); - - child.on('error', reject); - - // Write token to stdin and close it - child.stdin.write(token); - child.stdin.end(); - }); - } catch (error) { - console.warn('Could not authenticate gh CLI (may not be installed):', error); - // Don't throw - OAuth still succeeded even if gh CLI isn't available + async getCliEnvironment( + extraEnv?: NodeJS.ProcessEnv + ): Promise { + const token = await this.getStoredToken(); + if (!token) { + throw new Error('GitHub is not connected in Emdash'); } + + return { + ...process.env, + ...extraEnv, + GH_TOKEN: token, + GITHUB_TOKEN: token, + }; } /** - * Execute gh command with automatic re-auth on failure + * Execute gh using Emdash's stored token. */ private async execGH( command: string, options?: any ): Promise<{ stdout: string; stderr: string }> { - try { - const result = await execAsync(command, { encoding: 'utf8', ...options }); - return { - stdout: String(result.stdout), - stderr: String(result.stderr), - }; - } catch (error: any) { - // Check if it's an auth error - if (error.message && error.message.includes('not authenticated')) { - // Try to re-authenticate gh CLI with stored token - const token = await this.getStoredToken(); - if (token) { - await this.authenticateGHCLI(token); - - // Retry the command - const result = await execAsync(command, { encoding: 'utf8', ...options }); - return { - stdout: String(result.stdout), - stderr: String(result.stderr), - }; - } - } - throw error; - } + const env = await this.getCliEnvironment(options?.env); + const result = await execAsync(command, { encoding: 'utf8', ...options, env }); + return { + stdout: String(result.stdout), + stderr: String(result.stderr), + }; } /** @@ -629,21 +657,12 @@ export class GitHubService { */ async isAuthenticated(): Promise { try { - // First check if gh CLI is authenticated system-wide - const isGHAuth = await this.isGHCLIAuthenticated(); - if (isGHAuth) { - return true; - } - - // Fall back to checking stored token const token = await this.getStoredToken(); if (!token) { - // No stored token, user needs to authenticate return false; } - // Test the token by making a simple API call const user = await this.getUserInfo(token); return !!user; } catch (error) { @@ -653,45 +672,34 @@ export class GitHubService { } /** - * Check if gh CLI is authenticated system-wide - */ - private async isGHCLIAuthenticated(): Promise { - try { - // gh auth status exits with 0 if authenticated, non-zero otherwise - await execAsync('gh auth status'); - return true; - } catch (error) { - // Not authenticated or gh CLI not installed - return false; - } - } - - /** - * Get user information using GitHub API or CLI + * Get user information using the GitHub API. */ async getUserInfo(token: string): Promise { try { - let userData; - if (token) { - const response = await fetch('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github.v3+json', - 'X-GitHub-Api-Version': '2022-11-28', - }, - }); + if (!token) { + return null; + } - if (!response.ok) { - throw new Error(`GitHub API error: ${response.statusText}`); - } + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); - userData = await response.json(); - } else { - // Use gh CLI to get user info as fallback - const { stdout } = await this.execGH('gh api user'); - userData = JSON.parse(stdout); + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } + const userData = (await response.json()) as { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + }; + return { id: userData.id, login: userData.login, @@ -711,15 +719,8 @@ export class GitHubService { */ async getCurrentUser(): Promise { try { - // Check if authenticated first - const isAuth = await this.isAuthenticated(); - if (!isAuth) { - return null; - } - - // Get user info using the existing method - // Note: The token parameter is ignored in getUserInfo since it uses gh CLI - return await this.getUserInfo(''); + const token = await this.getStoredToken(); + return token ? await this.getUserInfo(token) : null; } catch (error) { console.error('Failed to get current user:', error); return null; @@ -761,7 +762,13 @@ export class GitHubService { /** * List open pull requests for the repository located at projectPath. */ - async getPullRequests(projectPath: string): Promise { + async getPullRequests( + projectPath: string, + options: GitHubPullRequestListOptions = {} + ): Promise { + const safeLimit = Math.min(Math.max(Number(options.limit) || 30, 1), 200); + const searchQuery = options.searchQuery?.trim() || ''; + try { const fields = [ 'number', @@ -775,33 +782,192 @@ export class GitHubService { 'author', 'headRepositoryOwner', 'headRepository', + 'reviewRequests', + 'latestReviews', + 'reviewDecision', + 'additions', + 'deletions', + 'statusCheckRollup', + 'labels', ]; - const { stdout } = await this.execGH(`gh pr list --state open --json ${fields.join(',')}`, { - cwd: projectPath, - }); + const searchFlag = searchQuery ? ` --search ${quoteShellArg(searchQuery)}` : ''; + const { stdout } = await this.execGH( + `gh pr list --state open --limit ${safeLimit}${searchFlag} --json ${fields.join(',')}`, + { cwd: projectPath } + ); const list = JSON.parse(stdout || '[]'); - if (!Array.isArray(list)) return []; + if (!Array.isArray(list)) { + return { prs: [], totalCount: 0 }; + } - return list.map((item: any) => ({ - number: item?.number, - title: item?.title || `PR #${item?.number ?? 'unknown'}`, - headRefName: item?.headRefName || '', - baseRefName: item?.baseRefName || '', - url: item?.url || '', - isDraft: item?.isDraft ?? false, - updatedAt: item?.updatedAt || null, - headRefOid: item?.headRefOid || undefined, - author: item?.author || null, - headRepositoryOwner: item?.headRepositoryOwner || null, - headRepository: item?.headRepository || null, - })); + const prs = sortByUpdatedAtDesc( + list.map((item: any) => ({ + number: item?.number, + title: item?.title || `PR #${item?.number ?? 'unknown'}`, + headRefName: item?.headRefName || '', + baseRefName: item?.baseRefName || '', + url: item?.url || '', + isDraft: item?.isDraft ?? false, + updatedAt: item?.updatedAt || null, + headRefOid: item?.headRefOid || undefined, + author: item?.author || null, + headRepositoryOwner: item?.headRepositoryOwner || null, + headRepository: item?.headRepository || null, + reviewDecision: item?.reviewDecision || null, + reviewers: this.buildReviewerList(item?.reviewRequests, item?.latestReviews), + additions: typeof item?.additions === 'number' ? item.additions : undefined, + deletions: typeof item?.deletions === 'number' ? item.deletions : undefined, + checksStatus: this.deriveChecksStatus(item?.statusCheckRollup), + labels: Array.isArray(item?.labels) ? item.labels : [], + })) + ); + + const totalCount = + (await this.getOpenPullRequestCount(projectPath, searchQuery)) ?? prs.length; + + return { prs, totalCount }; } catch (error) { console.error('Failed to list pull requests:', error); throw error; } } + private deriveChecksStatus(rollup: any[]): 'pass' | 'fail' | 'pending' | 'none' { + if (!Array.isArray(rollup) || rollup.length === 0) return 'none'; + + let hasFail = false; + let hasPending = false; + let hasPass = false; + + for (const item of rollup) { + if (item?.__typename === 'CheckRun') { + if (item.status !== 'COMPLETED') { + hasPending = true; + } else { + const c = item.conclusion; + if (['FAILURE', 'ACTION_REQUIRED', 'TIMED_OUT', 'STARTUP_FAILURE'].includes(c)) { + hasFail = true; + } else if (['SUCCESS', 'NEUTRAL', 'SKIPPED', 'CANCELLED'].includes(c)) { + hasPass = true; + } else { + hasPending = true; + } + } + } else { + // StatusContext + const s = item?.state; + if (['FAILURE', 'ERROR'].includes(s)) { + hasFail = true; + } else if (s === 'SUCCESS') { + hasPass = true; + } else { + hasPending = true; + } + } + } + + if (hasFail) return 'fail'; + if (hasPending) return 'pending'; + if (hasPass) return 'pass'; + return 'none'; + } + + private buildReviewerList(reviewRequests?: any[], latestReviews?: any[]): GitHubReviewer[] { + const reviewerMap = new Map(); + + // Add requested reviewers (pending review) + if (Array.isArray(reviewRequests)) { + for (const req of reviewRequests) { + const login = req?.login || req?.name; + if (login && typeof login === 'string') { + reviewerMap.set(login, { login, state: 'PENDING' }); + } + } + } + + // Add/overwrite with latest review states + if (Array.isArray(latestReviews)) { + for (const review of latestReviews) { + const login = review?.author?.login; + const state = review?.state; + if (login && typeof login === 'string') { + reviewerMap.set(login, { + login, + state: state || undefined, + }); + } + } + } + + return Array.from(reviewerMap.values()); + } + + private async getOpenPullRequestCount( + projectPath: string, + searchQuery?: string + ): Promise { + try { + const { stdout: repoStdout } = await this.execGH( + 'gh repo view --json nameWithOwner --jq .nameWithOwner', + { cwd: projectPath } + ); + const repoNameWithOwner = repoStdout.trim(); + if (!repoNameWithOwner) return null; + + const queryParts = [`repo:${repoNameWithOwner}`, 'is:pr', 'is:open']; + const normalizedSearchQuery = searchQuery?.trim(); + if (normalizedSearchQuery) { + queryParts.push(normalizedSearchQuery); + } + const query = queryParts.join(' '); + const { stdout } = await this.execGH( + `gh api search/issues --method GET -f q=${quoteShellArg(query)} --jq .total_count`, + { cwd: projectPath } + ); + + const totalCount = Number.parseInt(stdout.trim(), 10); + return Number.isFinite(totalCount) ? totalCount : null; + } catch (error) { + console.warn('Failed to fetch open PR count:', error); + return null; + } + } + + /** + * Get details for a specific pull request (base/head branches, title, number). + */ + async getPullRequestDetails( + projectPath: string, + prNumber: number + ): Promise<{ + baseRefName: string; + headRefName: string; + title: string; + number: number; + url: string; + } | null> { + try { + const fields = ['baseRefName', 'headRefName', 'title', 'number', 'url']; + const { stdout } = await this.execGH( + `gh pr view ${JSON.stringify(String(prNumber))} --json ${fields.join(',')}`, + { cwd: projectPath } + ); + const data = JSON.parse(stdout || 'null'); + if (!data || typeof data !== 'object') return null; + return { + baseRefName: data.baseRefName || '', + headRefName: data.headRefName || '', + title: data.title || '', + number: data.number || prNumber, + url: data.url || '', + }; + } catch (error) { + console.error('Failed to get pull request details:', error); + return null; + } + } + /** * Ensure a local branch exists for the given pull request by delegating to gh CLI. * Returns the branch name that now tracks the PR. @@ -812,32 +978,60 @@ export class GitHubService { branchName: string ): Promise { const safeBranch = branchName || `pr/${prNumber}`; - let previousRef: string | null = null; + // Fetch the PR ref directly without checking out (avoids touching the working tree) try { - const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: projectPath, - }); - const current = (stdout || '').trim(); - if (current) previousRef = current; - } catch { - previousRef = null; - } - - try { - await this.execGH( - `gh pr checkout ${JSON.stringify(String(prNumber))} --branch ${JSON.stringify(safeBranch)} --force`, + const prRef = `refs/pull/${prNumber}/head`; + await execAsync( + `git fetch origin ${JSON.stringify(prRef)}:${JSON.stringify(`refs/heads/${safeBranch}`)} --force`, { cwd: projectPath } ); - } catch (error) { - console.error('Failed to checkout pull request branch via gh:', error); - throw error; - } finally { - if (previousRef && previousRef !== safeBranch) { + } catch (fetchError) { + // Fallback: use gh pr checkout to create/sync the local PR branch. + console.warn( + 'Fetch-based PR branch creation failed, falling back to gh pr checkout:', + fetchError + ); + let previousRef: string | null = null; + try { + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: projectPath, + }); + const current = (stdout || '').trim(); + if (current) previousRef = current; + } catch { + previousRef = null; + } + + try { + await this.execGH( + `gh pr checkout ${JSON.stringify(String(prNumber))} --branch ${JSON.stringify(safeBranch)} --force`, + { cwd: projectPath } + ); + // Some gh/fork combinations can leave HEAD detached without a local branch ref. + // Ensure the requested local branch exists for downstream worktree creation. try { - await execAsync(`git checkout ${JSON.stringify(previousRef)}`, { cwd: projectPath }); - } catch (switchErr) { - console.warn('Failed to restore previous branch after PR checkout:', switchErr); + await execAsync( + `git show-ref --verify --quiet ${JSON.stringify(`refs/heads/${safeBranch}`)}`, + { cwd: projectPath } + ); + } catch { + await execAsync(`git branch --force ${JSON.stringify(safeBranch)} HEAD`, { + cwd: projectPath, + }); + } + } catch (error) { + console.error('Failed during PR branch checkout or local-ref creation:', error); + throw error; + } finally { + if (previousRef && previousRef !== safeBranch) { + try { + await execAsync(`git checkout ${JSON.stringify(previousRef)} --force`, { + cwd: projectPath, + }); + } catch (switchErr) { + console.warn('Failed to restore previous branch after PR checkout:', switchErr); + } } } } @@ -1073,35 +1267,52 @@ export class GitHubService { } } + private async withAuthStateLock(operation: () => Promise): Promise { + const previousLock = this.authStateLock; + let releaseLock!: () => void; + + this.authStateLock = new Promise((resolve) => { + releaseLock = resolve; + }); + + await previousLock; + + try { + return await operation(); + } finally { + releaseLock(); + } + } + /** * Logout and clear stored token */ async logout(): Promise { - // Run both operations in parallel since they're independent - await Promise.allSettled([ - // Logout from gh CLI - execAsync('echo Y | gh auth logout --hostname github.com').catch((error) => { - console.warn('Failed to logout from gh CLI (may not be installed or logged in):', error); - }), - // Clear keychain token - (async () => { - try { - const keytar = await import('keytar'); - await keytar.deletePassword(this.SERVICE_NAME, this.ACCOUNT_NAME); - } catch (error) { - console.error('Failed to clear keychain token:', error); - } - })(), - ]); + this.stopPolling(); + this.migrationAttempted = true; + + await this.withAuthStateLock(async () => { + try { + const keytar = await import('keytar'); + await keytar.deletePassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + await keytar.setPassword(this.SERVICE_NAME, this.MIGRATION_BLOCK_ACCOUNT, '1'); + } catch (error) { + console.error('Failed to clear keychain token:', error); + throw new Error('Failed to clear keychain token'); + } + }); } /** * Store authentication token securely */ - private async storeToken(token: string): Promise { + private async storeToken(token: string, source: 'user' | 'migration' = 'user'): Promise { try { const keytar = await import('keytar'); await keytar.setPassword(this.SERVICE_NAME, this.ACCOUNT_NAME, token); + if (source === 'user') { + await keytar.deletePassword(this.SERVICE_NAME, this.MIGRATION_BLOCK_ACCOUNT); + } } catch (error) { console.error('Failed to store token:', error); throw error; @@ -1109,18 +1320,190 @@ export class GitHubService { } /** - * Retrieve stored authentication token + * Retrieve stored authentication token. + * + * Migration for users authenticated before the gh-CLI decouple: their token + * only lives in the global gh CLI state. If the Emdash keychain is empty we + * try `gh auth token` once and persist the result, so PR lists and other + * gh-backed features keep working without asking the user to re-auth. */ - private async getStoredToken(): Promise { + async getStoredToken(): Promise { + if (this.migrationInFlight) { + return this.migrationInFlight; + } + try { const keytar = await import('keytar'); - return await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + const stored = await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + if (stored) return stored; + + const migrationBlocked = + (await keytar.getPassword(this.SERVICE_NAME, this.MIGRATION_BLOCK_ACCOUNT)) === '1'; + if (migrationBlocked) return null; + + if (this.migrationInFlight) { + return this.migrationInFlight; + } + + if (this.migrationAttempted) return null; } catch (error) { console.error('Failed to retrieve token:', error); return null; } + + const inFlight = this.migrateTokenFromGHCLI(); + this.migrationInFlight = inFlight; + + try { + return await inFlight; + } finally { + if (this.migrationInFlight === inFlight) { + this.migrationInFlight = null; + } + } + } + + private async migrateTokenFromGHCLI(): Promise { + return this.withAuthStateLock(async () => { + if (this.migrationAttempted) return null; + this.migrationAttempted = true; + + try { + const keytar = await import('keytar'); + + const migrationBlockedBeforeRead = + (await keytar.getPassword(this.SERVICE_NAME, this.MIGRATION_BLOCK_ACCOUNT)) === '1'; + if (migrationBlockedBeforeRead) return null; + + const { stdout } = await execAsync('gh auth token', { encoding: 'utf8' }); + const token = String(stdout).trim(); + if (!token) return null; + + // Re-check auth state while still serialized with logout(). + const stored = await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + if (stored) return stored; + + const migrationBlockedAfterRead = + (await keytar.getPassword(this.SERVICE_NAME, this.MIGRATION_BLOCK_ACCOUNT)) === '1'; + if (migrationBlockedAfterRead) return null; + + try { + await this.storeToken(token, 'migration'); + } catch (error) { + console.warn('Failed to persist migrated gh CLI token to keychain:', error); + } + return token; + } catch { + return null; + } + }); + } + // ----------------------------------------------------------------------- + // Repo events API — efficient polling with ETag caching + // ----------------------------------------------------------------------- + + /** ETag cache: repoNwo → { etag, rawEvents (unfiltered) } */ + private eventEtags = new Map(); + + /** + * Fetch recent repo events via the GitHub Events API. + * Uses ETag conditional requests: returns cached events on 304 (no new activity) + * so repeated polls are nearly free against the rate limit. + * + * Returns issue and PR creation/update events for automation triggers. + */ + async fetchRepoEvents(projectPath: string, eventTypes?: string[]): Promise { + try { + // Get repo nwo (owner/repo) + const { stdout: nwoOut } = await this.execGH( + 'gh repo view --json nameWithOwner --jq .nameWithOwner', + { cwd: projectPath } + ); + const nwo = nwoOut.trim(); + if (!nwo) return []; + + const cached = this.eventEtags.get(nwo); + + // Build gh api call with conditional ETag header + const etagHeader = cached?.etag + ? ` -H ${quoteShellArg(`If-None-Match: ${cached.etag}`)}` + : ''; + const cmd = `gh api /repos/${nwo}/events?per_page=30${etagHeader} --include`; + + const typesFilter = eventTypes + ? new Set(eventTypes) + : new Set(['IssuesEvent', 'PullRequestEvent']); + + const filterRawEvents = (raw: any[]): RepoEvent[] => + raw + .filter((e: any) => typesFilter.has(e.type)) + .map((e: any) => { + const item = e.payload?.issue ?? e.payload?.pull_request; + return { + id: String(e.id), + type: e.type, + action: e.payload?.action ?? '', + title: item?.title ?? '', + number: item?.number ?? 0, + url: item?.html_url ?? '', + labels: (item?.labels ?? []).map((l: any) => l?.name ?? '').filter(Boolean), + assignee: item?.assignee?.login ?? item?.user?.login ?? undefined, + branch: e.payload?.pull_request?.head?.ref ?? undefined, + createdAt: e.created_at ?? '', + }; + }); + + let stdout: string; + try { + const result = await this.execGH(cmd, { cwd: projectPath }); + stdout = result.stdout; + } catch (err: any) { + // gh api exits with non-zero on 304 — that means nothing changed + const msg = err?.stderr || err?.message || ''; + if (msg.includes('304') || msg.includes('Not Modified')) { + return filterRawEvents(cached?.rawEvents ?? []); + } + throw err; + } + + // Parse response: --include prepends HTTP headers before the JSON body + const headerEnd = stdout.indexOf('\r\n\r\n'); + const headerBlock = headerEnd >= 0 ? stdout.slice(0, headerEnd) : ''; + const jsonBody = headerEnd >= 0 ? stdout.slice(headerEnd + 4) : stdout; + + // Extract ETag from response headers + const etagMatch = headerBlock.match(/ETag:\s*"?([^"\r\n]+)"?/i); + const newEtag = etagMatch?.[1] ?? ''; + + const rawEvents = JSON.parse(jsonBody || '[]'); + if (!Array.isArray(rawEvents)) return filterRawEvents(cached?.rawEvents ?? []); + + // Cache unfiltered events for next poll (so different eventTypes can reuse the cache) + if (newEtag) { + this.eventEtags.set(nwo, { etag: newEtag, rawEvents }); + } + + return filterRawEvents(rawEvents); + } catch (error) { + console.error('Failed to fetch repo events:', error); + return []; + } } } +/** Structured repo event from the GitHub Events API */ +export interface RepoEvent { + id: string; + type: string; + action: string; + title: string; + number: number; + url: string; + labels: string[]; + assignee?: string; + branch?: string; + createdAt: string; +} + // Export singleton instance export const githubService = new GitHubService(); diff --git a/src/main/services/GitLabService.ts b/src/main/services/GitLabService.ts index f2b8868968..46a0a4a743 100644 --- a/src/main/services/GitLabService.ts +++ b/src/main/services/GitLabService.ts @@ -19,6 +19,19 @@ interface GitLabIssueSummary { updated_at?: string | null; } +interface GitLabMRSummary { + id: number; + iid: number; // project-scoped MR number + title: string; + web_url?: string | null; + state?: string | null; // "opened" | "closed" | "merged" | "locked" + source_branch?: string | null; + target_branch?: string | null; + assignee?: { name: string; username: string } | null; + labels?: string[] | null; + updated_at?: string | null; +} + type GitLabCreds = { siteUrl: string; }; @@ -107,6 +120,34 @@ export class GitLabService { } } + async initialFetchMRs( + projectPath?: string, + limit: number = 10 + ): Promise<{ success: boolean; mrs?: GitLabMRSummary[]; error?: string }> { + try { + const path = projectPath?.trim(); + const clampedLimit = Math.max(1, Math.min(typeof limit === 'number' ? limit : 10, 100)); + const { siteUrl, token } = await this.requireAuth(); + if (!siteUrl || !token) { + return { success: false, error: 'GitLab is not configured' }; + } + if (!path) { + return { success: false, error: 'Project path is required' }; + } + const { success, id, error } = await this.resolveProjectId(path); + if (!success) { + return { success: false, error }; + } + if (!id) { + return { success: false, error: 'Unable to resolve project ID' }; + } + const mrs = await this.fetchMergeRequests(id, clampedLimit); + return { success: true, mrs }; + } catch (e: any) { + return { success: false, error: e?.message }; + } + } + async searchIssues( projectPath: string | undefined, searchTerm: string, @@ -220,6 +261,36 @@ export class GitLabService { } } + private async fetchMergeRequests( + projectId: string, + limit: number = 10 + ): Promise { + const { siteUrl, token } = await this.requireAuth(); + if (!siteUrl || !token) { + throw new Error('GitLab is not configured'); + } + const url = new URL( + `${siteUrl}/api/v4/projects/${projectId}/merge_requests?state=opened&order_by=updated_at&sort=desc&per_page=${limit}` + ); + const response = await this.doRequest(url, token, 'GET'); + if (!response.ok) { + throw new Error('Could not fetch merge requests'); + } + const data = (await response.json()) as any[]; + return data.map((mr) => ({ + id: mr.id, + iid: mr.iid, + title: mr.title, + web_url: mr.web_url, + state: mr.state, + source_branch: mr.source_branch, + target_branch: mr.target_branch, + assignee: mr.assignee ?? (Array.isArray(mr.assignees) ? (mr.assignees[0] ?? null) : null), + labels: mr.labels, + updated_at: mr.updated_at, + })); + } + private normalizeIssues(issues: any[]): GitLabIssueSummary[] { return issues.map((issue) => ({ id: issue.id, diff --git a/src/main/services/GitService.ts b/src/main/services/GitService.ts index 530663ec10..a7c8b0eac0 100644 --- a/src/main/services/GitService.ts +++ b/src/main/services/GitService.ts @@ -8,10 +8,28 @@ import { MAX_DIFF_CONTENT_BYTES, MAX_DIFF_OUTPUT_BYTES, } from '../utils/diffParser'; -import type { DiffLine, DiffResult } from '../utils/diffParser'; +import { parseGitStatusOutput, parseNumstatOutput } from '../utils/gitStatusParser'; +import type { DiffResult } from '../utils/diffParser'; +import { + buildAddedDiffLines, + buildDeletedDiffLines, + buildOptionalDiffWarnings, + isMaxBufferError, + type CappedTextResult, +} from './git-core/diffShared'; +import { updateIndexShared } from './git-core/indexShared'; +import { revertFileShared } from './git-core/revertShared'; +import { + applyUntrackedLineCounts, + buildStatusChanges, + MAX_UNTRACKED_LINECOUNT_BYTES, +} from './git-core/statusShared'; +import { resolveWorkingTreeDiffResult } from './git-core/workingTreeDiffShared'; +import type { GitChange, GitIndexUpdateArgs } from '../../shared/git/types'; const execFileAsync = promisify(execFile); -const MAX_UNTRACKED_LINECOUNT_BYTES = 512 * 1024; +const FORCE_LOAD_DIFF_CONTENT_BYTES = 5 * 1024 * 1024; +const FORCE_LOAD_DIFF_OUTPUT_BYTES = 30 * 1024 * 1024; async function countFileNewlinesCapped(filePath: string, maxBytes: number): Promise { let stat: fs.Stats; @@ -39,329 +57,391 @@ async function countFileNewlinesCapped(filePath: string, maxBytes: number): Prom }); } -async function readFileTextCapped(filePath: string, maxBytes: number): Promise { +async function readFileTextCapped(filePath: string, maxBytes: number): Promise { let stat: fs.Stats; try { stat = await fs.promises.stat(filePath); } catch { - return null; + return { exists: false, tooLarge: false }; } - if (!stat.isFile() || stat.size > maxBytes) { - return null; + if (!stat.isFile()) { + return { exists: false, tooLarge: false }; + } + if (stat.size > maxBytes) { + return { exists: true, tooLarge: true }; } try { - return await fs.promises.readFile(filePath, 'utf8'); + const contentBuffer = await fs.promises.readFile(filePath); + if (contentBuffer.includes(0x00)) { + return { exists: true, tooLarge: false, isBinary: true }; + } + + const content = contentBuffer.toString('utf8'); + return { + exists: true, + tooLarge: false, + content: stripTrailingNewline(content), + }; } catch { - return null; + return { exists: true, tooLarge: false }; } } -export type GitChange = { - path: string; - status: string; - additions: number; - deletions: number; - isStaged: boolean; -}; - -export async function getStatus(taskPath: string): Promise { +async function readGitTextCapped( + taskPath: string, + objectSpec: string, + maxBytes: number +): Promise { try { - await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { + const { stdout: sizeStdout } = await execFileAsync('git', ['cat-file', '-s', objectSpec], { cwd: taskPath, }); + const size = parseInt(sizeStdout.trim(), 10); + if (Number.isFinite(size) && size > maxBytes) { + return { exists: true, tooLarge: true }; + } } catch { - return []; + return { exists: false, tooLarge: false }; } - const { stdout: statusOutput } = await execFileAsync( - 'git', - ['status', '--porcelain', '--untracked-files=all'], - { + try { + const { stdout } = (await execFileAsync('git', ['show', objectSpec], { cwd: taskPath, - } - ); + maxBuffer: maxBytes, + encoding: 'buffer', + })) as { stdout: Buffer }; - if (!statusOutput.trim()) return []; + if (stdout.includes(0x00)) { + return { exists: true, tooLarge: false, isBinary: true }; + } - const statusLines = statusOutput - .split('\n') - .map((l) => l.replace(/\r$/, '')) - .filter((l) => l.length > 0); + return { + exists: true, + tooLarge: false, + content: stripTrailingNewline(stdout.toString('utf8')), + }; + } catch (error) { + if (isMaxBufferError(error)) { + return { exists: true, tooLarge: true }; + } + return { exists: true, tooLarge: false }; + } +} - // Parse status lines into file entries - const entries: Array<{ - filePath: string; - status: string; - statusCode: string; - isStaged: boolean; - }> = []; +async function resolveReviewBaseRef(taskPath: string, baseRef: string): Promise { + try { + const { stdout } = await execFileAsync('git', ['merge-base', baseRef, 'HEAD'], { + cwd: taskPath, + }); + const mergeBase = stdout.trim(); + if (mergeBase) return mergeBase; + } catch { + // Fall back to the requested base ref when merge-base cannot be resolved. + } - for (const line of statusLines) { - const statusCode = line.substring(0, 2); - let filePath = line.substring(3); - if (statusCode.includes('R') && filePath.includes('->')) { - const parts = filePath.split('->'); - filePath = parts[parts.length - 1].trim(); - } + return baseRef; +} - let status = 'modified'; - if (statusCode.includes('A') || statusCode.includes('?')) status = 'added'; - else if (statusCode.includes('D')) status = 'deleted'; - else if (statusCode.includes('R')) status = 'renamed'; - else if (statusCode.includes('M')) status = 'modified'; - - const isStaged = statusCode[0] !== ' ' && statusCode[0] !== '?'; - entries.push({ filePath, status, statusCode, isStaged }); - } - - // Batch: run ONE staged numstat and ONE unstaged numstat for ALL files at once and parse the file - // into a map of file paths to their additions and deletions - // Map { filePath: { add: number, del: number } } - // Resolve git's rename notation to the new (destination) file path. - // Formats: "old.ts => new.ts" or "src/{Old => New}.tsx" - const resolveRenamePath = (file: string): string => { - if (!file.includes(' => ')) return file; - // In-place rename with braces: "src/{Old => New}.tsx" - if (file.includes('{')) { - return file.replace(/\{[^}]+ => ([^}]+)\}/g, '$1').replace(/\/\//g, '/'); +export async function getStatus(taskPath: string): Promise { + try { + try { + await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: taskPath, + }); + } catch { + return []; } - // Full rename: "old.ts => new.ts" - return file.split(' => ').pop()!.trim(); - }; - const parseNumstatMap = (stdout: string): Map => { - const map = new Map(); - if (!stdout || !stdout.trim()) return map; - for (const line of stdout.trim().split('\n')) { - if (!line.trim()) continue; - const parts = line.split('\t'); - if (parts.length >= 3) { - const add = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0; - const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0; - const file = resolveRenamePath(parts.slice(2).join('\t')); - const existing = map.get(file); - if (existing) { - existing.add += add; - existing.del += del; - } else { - map.set(file, { add, del }); - } + // Run git commands in parallel with flags tuned for performance: + // --no-optional-locks: avoid blocking on concurrent git processes + // --no-ahead-behind: skip commit-graph walk for tracking info + const statusPromise = (async () => { + try { + const { stdout } = await execFileAsync( + 'git', + [ + '--no-optional-locks', + 'status', + '--porcelain=v2', + '-z', + '--no-ahead-behind', + '--untracked-files=all', + ], + { + cwd: taskPath, + maxBuffer: MAX_DIFF_OUTPUT_BYTES, + } + ); + return stdout; + } catch { + // Fallback for older git versions that do not support porcelain v2. + const { stdout } = await execFileAsync( + 'git', + ['--no-optional-locks', 'status', '--porcelain', '--untracked-files=all'], + { + cwd: taskPath, + maxBuffer: MAX_DIFF_OUTPUT_BYTES, + } + ); + return stdout; } - } - return map; - }; + })(); - const [stagedResult, unstagedResult] = await Promise.all([ - execFileAsync('git', ['diff', '--numstat', '--cached'], { cwd: taskPath }).catch(() => ({ + const stagedPromise = execFileAsync( + 'git', + ['--no-optional-locks', 'diff', '--numstat', '--cached'], + { + cwd: taskPath, + maxBuffer: MAX_DIFF_OUTPUT_BYTES, + } + ).catch(() => ({ stdout: '', stderr: '', - })), - execFileAsync('git', ['diff', '--numstat'], { cwd: taskPath }).catch(() => ({ + })); + + const unstagedPromise = execFileAsync('git', ['--no-optional-locks', 'diff', '--numstat'], { + cwd: taskPath, + maxBuffer: MAX_DIFF_OUTPUT_BYTES, + }).catch(() => ({ stdout: '', stderr: '', - })), - ]); + })); - const stagedMap = parseNumstatMap(stagedResult.stdout); - const unstagedMap = parseNumstatMap(unstagedResult.stdout); - - // Count lines for untracked files in parallel - const untrackedEntries = entries.filter( - (e) => e.statusCode.includes('?') && !stagedMap.has(e.filePath) && !unstagedMap.has(e.filePath) - ); - const untrackedCounts = await Promise.all( - untrackedEntries.map((e) => - countFileNewlinesCapped(path.join(taskPath, e.filePath), MAX_UNTRACKED_LINECOUNT_BYTES) - ) - ); - const untrackedMap = new Map(); - untrackedEntries.forEach((e, i) => { - if (typeof untrackedCounts[i] === 'number') { - untrackedMap.set(e.filePath, untrackedCounts[i]!); - } - }); - - // Assemble results - const changes: GitChange[] = entries.map((e) => { - const staged = stagedMap.get(e.filePath); - const unstaged = unstagedMap.get(e.filePath); - let additions = (staged?.add ?? 0) + (unstaged?.add ?? 0); - const deletions = (staged?.del ?? 0) + (unstaged?.del ?? 0); + const [statusOutput, stagedResult, unstagedResult] = await Promise.all([ + statusPromise, + stagedPromise, + unstagedPromise, + ]); - if (additions === 0 && deletions === 0 && untrackedMap.has(e.filePath)) { - additions = untrackedMap.get(e.filePath)!; - } + if (!statusOutput.trim()) return []; - return { - path: e.filePath, - status: e.status, - additions, - deletions, - isStaged: e.isStaged, - }; - }); + const entries = parseGitStatusOutput(statusOutput); - return changes; -} + const stagedMap = parseNumstatOutput(stagedResult.stdout); + const unstagedMap = parseNumstatOutput(unstagedResult.stdout); + const { changes, untrackedPathsNeedingCounts } = buildStatusChanges( + entries, + stagedMap, + unstagedMap + ); -export async function stageFile(taskPath: string, filePath: string): Promise { - await execFileAsync('git', ['add', '--', filePath], { cwd: taskPath }); -} + if (untrackedPathsNeedingCounts.length === 0) { + return changes; + } -export async function stageAllFiles(taskPath: string): Promise { - await execFileAsync('git', ['add', '-A'], { cwd: taskPath }); -} + const counts = await Promise.all( + untrackedPathsNeedingCounts.map((filePath) => + countFileNewlinesCapped(path.join(taskPath, filePath), MAX_UNTRACKED_LINECOUNT_BYTES) + ) + ); + const untrackedMap = new Map(); + for (let i = 0; i < untrackedPathsNeedingCounts.length; i++) { + untrackedMap.set(untrackedPathsNeedingCounts[i], counts[i] ?? null); + } -export async function unstageFile(taskPath: string, filePath: string): Promise { - try { - await execFileAsync('git', ['reset', 'HEAD', '--', filePath], { cwd: taskPath }); + return applyUntrackedLineCounts(changes, untrackedMap); } catch { - // HEAD may not exist (no commits yet) — use rm --cached instead - await execFileAsync('git', ['rm', '--cached', '--', filePath], { cwd: taskPath }); + return []; } } -export async function revertFile( - taskPath: string, - filePath: string -): Promise<{ action: 'unstaged' | 'reverted' }> { - // Validate filePath doesn't escape the worktree +function normalizeLocalRelativeFilePath(taskPath: string, filePath: string): string { const absPath = path.resolve(taskPath, filePath); const resolvedTaskPath = path.resolve(taskPath); if (!absPath.startsWith(resolvedTaskPath + path.sep) && absPath !== resolvedTaskPath) { throw new Error('File path is outside the worktree'); } - // Check if file is tracked in git (exists in HEAD) - let fileExistsInHead = false; - try { - await execFileAsync('git', ['cat-file', '-e', `HEAD:${filePath}`], { cwd: taskPath }); - fileExistsInHead = true; - } catch { - // File doesn't exist in HEAD (it's a new/untracked file), delete it - if (fs.existsSync(absPath)) { - fs.unlinkSync(absPath); - } - return { action: 'reverted' }; + const relativePath = path.relative(resolvedTaskPath, absPath); + const normalizedPath = relativePath.split(path.sep).join('/'); + if (!normalizedPath || normalizedPath === '.') { + throw new Error('Invalid file path'); } - // File exists in HEAD, revert it - if (fileExistsInHead) { - try { - await execFileAsync('git', ['checkout', 'HEAD', '--', filePath], { cwd: taskPath }); - } catch (error) { - // If checkout fails, don't delete the file - throw the error instead - throw new Error( - `Failed to revert file: ${error instanceof Error ? error.message : String(error)}` - ); - } - } - return { action: 'reverted' }; + return normalizedPath; } -export async function getFileDiff(taskPath: string, filePath: string): Promise { - const absPath = path.resolve(taskPath, filePath); - const resolvedTaskPath = path.resolve(taskPath); - if (!absPath.startsWith(resolvedTaskPath + path.sep) && absPath !== resolvedTaskPath) { - throw new Error('File path is outside the worktree'); - } - - // Helper: fetch content at HEAD with size guard - const getOriginalContent = async (): Promise => { - try { - const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], { +export async function updateIndex(taskPath: string, args: GitIndexUpdateArgs): Promise { + await updateIndexShared(args, { + stageAll: async () => { + await execFileAsync('git', ['add', '-A'], { cwd: taskPath }); + }, + resetAll: async () => { + try { + await execFileAsync('git', ['reset', 'HEAD', '--', '.'], { cwd: taskPath }); + return true; + } catch { + return false; + } + }, + listStagedPaths: async () => { + const { stdout } = await execFileAsync('git', ['diff', '--cached', '--name-only'], { cwd: taskPath, - maxBuffer: MAX_DIFF_CONTENT_BYTES, }); - return stripTrailingNewline(stdout); - } catch { - return undefined; - } + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + }, + stagePaths: async (filePaths) => { + await execFileAsync('git', ['add', '--', ...filePaths], { cwd: taskPath }); + }, + resetPaths: async (filePaths) => { + try { + await execFileAsync('git', ['reset', 'HEAD', '--', ...filePaths], { cwd: taskPath }); + return true; + } catch { + return false; + } + }, + resetPath: async (filePath) => { + try { + await execFileAsync('git', ['reset', 'HEAD', '--', filePath], { cwd: taskPath }); + return true; + } catch { + return false; + } + }, + removePathFromIndex: async (filePath) => { + await execFileAsync('git', ['rm', '--cached', '--', filePath], { cwd: taskPath }); + }, + }); +} + +export async function revertFile( + taskPath: string, + filePath: string +): Promise<{ action: 'reverted' }> { + return revertFileShared(filePath, { + normalizeFilePath: (pathInput) => normalizeLocalRelativeFilePath(taskPath, pathInput), + existsInHead: async (safePath) => { + try { + await execFileAsync('git', ['cat-file', '-e', `HEAD:${safePath}`], { cwd: taskPath }); + return true; + } catch { + return false; + } + }, + deleteUntracked: async (safePath) => { + const absPath = path.resolve(taskPath, safePath); + if (fs.existsSync(absPath)) { + fs.unlinkSync(absPath); + } + }, + checkoutHead: async (safePath) => { + try { + await execFileAsync('git', ['checkout', 'HEAD', '--', safePath], { cwd: taskPath }); + } catch (error) { + throw new Error( + `Failed to revert file: ${error instanceof Error ? error.message : String(error)}` + ); + } + }, + }); +} + +export async function getFileDiff( + taskPath: string, + filePath: string, + baseRef?: string, + forceLarge?: boolean +): Promise { + const safeFilePath = normalizeLocalRelativeFilePath(taskPath, filePath); + const diffContentLimit = forceLarge ? FORCE_LOAD_DIFF_CONTENT_BYTES : MAX_DIFF_CONTENT_BYTES; + const diffOutputLimit = forceLarge ? FORCE_LOAD_DIFF_OUTPUT_BYTES : MAX_DIFF_OUTPUT_BYTES; + + const reviewBaseRef = baseRef ? await resolveReviewBaseRef(taskPath, baseRef) : undefined; + const originalRef = reviewBaseRef || 'HEAD'; + + // Helper: fetch content at the base ref with size guard + const getOriginalContent = async (): Promise => { + return readGitTextCapped(taskPath, `${originalRef}:${safeFilePath}`, diffContentLimit); }; - // Helper: read current file from disk with size guard - const getModifiedContent = async (): Promise => { - const content = await readFileTextCapped(path.join(taskPath, filePath), MAX_DIFF_CONTENT_BYTES); - return content !== null ? stripTrailingNewline(content) : undefined; + const getModifiedContent = async (): Promise => { + if (baseRef) { + return readGitTextCapped(taskPath, `HEAD:${safeFilePath}`, diffContentLimit); + } + + return readFileTextCapped(path.join(taskPath, safeFilePath), diffContentLimit); }; + const [original, modified] = await Promise.all([getOriginalContent(), getModifiedContent()]); + + // Fast path: if we already know this file is binary or too large, skip expensive diff generation. + if (original.isBinary || modified.isBinary) { + return { lines: [], mode: 'binary', isBinary: true }; + } + if (original.tooLarge || modified.tooLarge) { + return resolveWorkingTreeDiffResult({ + diffStdout: undefined, + diffLines: [], + hasHunk: false, + diffTooLarge: true, + diffFailed: false, + original, + modified, + }); + } + // Step 1: Run git diff let diffStdout: string | undefined; + let diffTooLarge = false; + let diffFailed = false; try { - const { stdout } = await execFileAsync( - 'git', - ['diff', '--no-color', '--unified=2000', 'HEAD', '--', filePath], - { cwd: taskPath, maxBuffer: MAX_DIFF_OUTPUT_BYTES } - ); + const diffArgs = baseRef + ? ['diff', '--no-color', '--unified=2000', originalRef, 'HEAD', '--', safeFilePath] + : ['diff', '--no-color', '--unified=2000', 'HEAD', '--', safeFilePath]; + const { stdout } = await execFileAsync('git', diffArgs, { + cwd: taskPath, + maxBuffer: diffOutputLimit, + }); diffStdout = stdout; - } catch { + } catch (error) { + diffTooLarge = isMaxBufferError(error); + diffFailed = !diffTooLarge; // git diff failed (no HEAD, untracked file, etc.) — fall through to content-only path } - // Step 2: Parse diff and check binary + // Step 2: Parse diff and check mode + let diffLines: DiffResult['lines'] = []; + let hasHunk = false; if (diffStdout !== undefined) { - const { lines, isBinary } = parseDiffLines(diffStdout); - - if (isBinary) { - return { lines: [], isBinary: true }; - } - - // Step 3: Fetch content (only for non-binary) - const [originalContent, modifiedContent] = await Promise.all([ - getOriginalContent(), - getModifiedContent(), - ]); - - // Step 4: Handle empty diff (untracked or deleted file that git reports as empty diff) - if (lines.length === 0) { - if (modifiedContent !== undefined) { - return { - lines: modifiedContent.split('\n').map((l) => ({ right: l, type: 'add' as const })), - modifiedContent, - }; - } - if (originalContent !== undefined) { - return { - lines: originalContent.split('\n').map((l) => ({ left: l, type: 'del' as const })), - originalContent, - }; - } - return { lines: [] }; + const parsed = parseDiffLines(diffStdout); + if (parsed.isBinary) { + return { lines: [], mode: 'binary', isBinary: true }; } - - return { lines, originalContent, modifiedContent }; + diffLines = parsed.lines; + hasHunk = parsed.hasHunk; } - // Fallback: git diff failed — try content-only approach - const [originalContent, modifiedContent] = await Promise.all([ - getOriginalContent(), - getModifiedContent(), - ]); - - if (modifiedContent !== undefined) { - return { - lines: modifiedContent.split('\n').map((l) => ({ right: l, type: 'add' as const })), - originalContent, - modifiedContent, - }; - } - if (originalContent !== undefined) { - return { - lines: originalContent.split('\n').map((l) => ({ left: l, type: 'del' as const })), - originalContent, - }; - } - return { lines: [] }; + return resolveWorkingTreeDiffResult({ + diffStdout, + diffLines, + hasHunk, + diffTooLarge, + diffFailed, + original, + modified, + }); } /** Commit staged files (no push). Returns the commit hash. */ -export async function commit(taskPath: string, message: string): Promise<{ hash: string }> { +export async function commit( + taskPath: string, + message: string, + options: { noVerify?: boolean } = {} +): Promise<{ hash: string }> { if (!message || !message.trim()) { throw new Error('Commit message cannot be empty'); } - await execFileAsync('git', ['commit', '-m', message], { cwd: taskPath }); + const args = ['commit', '-m', message]; + if (options.noVerify) { + args.push('--no-verify'); + } + await execFileAsync('git', args, { cwd: taskPath }); const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: taskPath }); return { hash: stdout.trim() }; } @@ -407,6 +487,7 @@ export async function getLog( subject: string; body: string; author: string; + authorEmail: string; date: string; isPushed: boolean; tags: string[]; @@ -469,7 +550,7 @@ export async function getLog( const FIELD_SEP = '---FIELD_SEP---'; const RECORD_SEP = '---RECORD_SEP---'; - const format = `${RECORD_SEP}%H${FIELD_SEP}%s${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%D${FIELD_SEP}%b`; + const format = `${RECORD_SEP}%H${FIELD_SEP}%s${FIELD_SEP}%an${FIELD_SEP}%aI${FIELD_SEP}%D${FIELD_SEP}%ae${FIELD_SEP}%b`; const { stdout } = await execFileAsync( 'git', ['log', `--max-count=${maxCount}`, `--skip=${skip}`, `--pretty=format:${format}`, '--'], @@ -493,8 +574,9 @@ export async function getLog( return { hash: parts[0] || '', subject: parts[1] || '', - body: (parts[5] || '').trim(), + body: (parts[6] || '').trim(), author: parts[2] || '', + authorEmail: parts[5] || '', date: parts[3] || '', isPushed: skip + index >= aheadCount, tags, @@ -583,25 +665,16 @@ export async function getCommitFiles( export async function getCommitFileDiff( taskPath: string, commitHash: string, - filePath: string + filePath: string, + forceLarge?: boolean ): Promise { - const absPath = path.resolve(taskPath, filePath); - const resolvedTaskPath = path.resolve(taskPath); - if (!absPath.startsWith(resolvedTaskPath + path.sep) && absPath !== resolvedTaskPath) { - throw new Error('File path is outside the worktree'); - } + const safeFilePath = normalizeLocalRelativeFilePath(taskPath, filePath); + const diffContentLimit = forceLarge ? FORCE_LOAD_DIFF_CONTENT_BYTES : MAX_DIFF_CONTENT_BYTES; + const diffOutputLimit = forceLarge ? FORCE_LOAD_DIFF_OUTPUT_BYTES : MAX_DIFF_OUTPUT_BYTES; // Helper: fetch content at a given ref with size guard - const getContentAt = async (ref: string): Promise => { - try { - const { stdout } = await execFileAsync('git', ['show', `${ref}:${filePath}`], { - cwd: taskPath, - maxBuffer: MAX_DIFF_CONTENT_BYTES, - }); - return stripTrailingNewline(stdout); - } catch { - return undefined; - } + const getContentAt = async (ref: string): Promise => { + return readGitTextCapped(taskPath, `${ref}:${safeFilePath}`, diffContentLimit); }; // Check if this is a root commit (no parent) @@ -613,65 +686,153 @@ export async function getCommitFileDiff( } if (!hasParent) { - const modifiedContent = await getContentAt(commitHash); + const modified = await getContentAt(commitHash); + const modifiedContent = modified.content; + + if (modified.isBinary) { + const result = { lines: [], mode: 'binary' as const, isBinary: true }; + return result; + } + if (modified.tooLarge) { + const result = { lines: [], mode: 'largeText' as const }; + return result; + } if (modifiedContent === undefined) { - return { lines: [] }; + const result = { lines: [], mode: 'unrenderable' as const }; + return result; } if (modifiedContent === '') { - return { lines: [], modifiedContent }; + const result = { lines: [], mode: 'text' as const, modifiedContent }; + return result; } - return { - lines: modifiedContent.split('\n').map((l) => ({ right: l, type: 'add' as const })), + const lines = buildAddedDiffLines(modifiedContent); + const result = { + lines, + mode: 'text' as const, modifiedContent, + warnings: buildOptionalDiffWarnings(undefined, modifiedContent, lines), }; + return result; + } + + const [original, modified] = await Promise.all([ + getContentAt(`${commitHash}~1`), + getContentAt(commitHash), + ]); + if (original.isBinary || modified.isBinary) { + const result = { lines: [], mode: 'binary' as const, isBinary: true }; + return result; + } + if (original.tooLarge || modified.tooLarge) { + const originalContent = original.content; + const modifiedContent = modified.content; + const result = { + lines: [], + mode: 'largeText' as const, + originalContent, + modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, []), + }; + return result; } // Run diff let diffStdout: string | undefined; + let diffTooLarge = false; + let diffFailed = false; try { const { stdout } = await execFileAsync( 'git', - ['diff', '--no-color', '--unified=2000', `${commitHash}~1`, commitHash, '--', filePath], - { cwd: taskPath, maxBuffer: MAX_DIFF_OUTPUT_BYTES } + ['diff', '--no-color', '--unified=2000', `${commitHash}~1`, commitHash, '--', safeFilePath], + { cwd: taskPath, maxBuffer: diffOutputLimit } ); diffStdout = stdout; - } catch { + } catch (error) { + diffTooLarge = isMaxBufferError(error); + diffFailed = !diffTooLarge; // diff too large or git error — fall through to content-only path } - let diffLines: DiffLine[] = []; + let diffLines: DiffResult['lines'] = []; + let hasHunk = false; if (diffStdout !== undefined) { - const { lines, isBinary } = parseDiffLines(diffStdout); + const { lines, isBinary, hasHunk: parsedHasHunk } = parseDiffLines(diffStdout); if (isBinary) { - return { lines: [], isBinary: true }; + const result = { lines: [], mode: 'binary' as const, isBinary: true }; + return result; } diffLines = lines; + hasHunk = parsedHasHunk; } - // Fetch content AFTER binary check to avoid fetching binary blobs - const [originalContent, modifiedContent] = await Promise.all([ - getContentAt(`${commitHash}~1`), - getContentAt(commitHash), - ]); + const originalContent = original.content; + const modifiedContent = modified.content; + const warnings = buildOptionalDiffWarnings(originalContent, modifiedContent, diffLines); - if (diffLines.length > 0) return { lines: diffLines, originalContent, modifiedContent }; + if (diffTooLarge || original.tooLarge || modified.tooLarge) { + const result = { + lines: diffLines, + mode: 'largeText' as const, + originalContent, + modifiedContent, + warnings, + }; + return result; + } + + if (diffLines.length > 0) { + const result = { + lines: diffLines, + mode: 'text' as const, + originalContent, + modifiedContent, + warnings, + }; + return result; + } + + if (!hasHunk && diffStdout !== undefined && diffStdout.trim()) { + const result = { + lines: [], + mode: 'unrenderable' as const, + originalContent, + modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, []), + }; + return result; + } // Fallback: diff failed or empty — determine from content if (modifiedContent !== undefined && modifiedContent !== '') { - return { - lines: modifiedContent.split('\n').map((l) => ({ right: l, type: 'add' as const })), + const lines = buildAddedDiffLines(modifiedContent); + const result = { + lines, + mode: 'text' as const, originalContent, modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, lines), }; + return result; } if (originalContent !== undefined) { - return { - lines: originalContent.split('\n').map((l) => ({ left: l, type: 'del' as const })), + const lines = buildDeletedDiffLines(originalContent); + const result = { + lines, + mode: 'text' as const, originalContent, modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, lines), }; + return result; } - return { lines: [], originalContent, modifiedContent }; + const fallbackMode: DiffResult['mode'] = diffFailed ? 'unrenderable' : 'text'; + const result = { + lines: [], + mode: fallbackMode, + originalContent, + modifiedContent, + }; + return result; } /** Soft-reset the latest commit. Returns the commit message that was reset. */ diff --git a/src/main/services/LifecycleScriptsService.ts b/src/main/services/LifecycleScriptsService.ts index 9d11c754b0..5a4911355b 100644 --- a/src/main/services/LifecycleScriptsService.ts +++ b/src/main/services/LifecycleScriptsService.ts @@ -1,13 +1,20 @@ import fs from 'fs'; import path from 'path'; import { log } from '../lib/logger'; -import type { LifecyclePhase, LifecycleScriptConfig } from '@shared/lifecycle'; +import type { LifecycleScriptConfig } from '@shared/lifecycle'; + +export interface WorkspaceProviderConfig { + type: 'script'; + provisionCommand: string; + terminateCommand: string; +} export interface EmdashConfig { preservePatterns?: string[]; scripts?: LifecycleScriptConfig; shellSetup?: string; tmux?: boolean; + workspaceProvider?: WorkspaceProviderConfig; } /** @@ -35,7 +42,7 @@ class LifecycleScriptsService { /** * Get a specific lifecycle script command if configured. */ - getScript(projectPath: string, phase: LifecyclePhase): string | null { + getScript(projectPath: string, phase: keyof LifecycleScriptConfig): string | null { const config = this.readConfig(projectPath); const scripts = config?.scripts; const script = scripts?.[phase]; diff --git a/src/main/services/McpService.ts b/src/main/services/McpService.ts new file mode 100644 index 0000000000..8dbe197751 --- /dev/null +++ b/src/main/services/McpService.ts @@ -0,0 +1,272 @@ +import { log } from '../lib/logger'; +import { readServers, writeServers } from './mcp/configIO'; +import { getAgentMcpMeta, getAllMcpAgentIds } from './mcp/configPaths'; +import { adaptForward, adaptReverse } from './mcp/adapters'; +import { loadCatalog } from './mcp/catalog'; +import type { + AgentMcpMeta, + McpServer, + McpLoadAllResponse, + ServerMap, + RawServerEntry, +} from '@shared/mcp/types'; + +export class McpService { + private _writeLock = Promise.resolve(); + + private async withWriteLock(fn: () => Promise): Promise { + const prev = this._writeLock; + let resolve: () => void; + this._writeLock = new Promise((r) => { + resolve = r; + }); + await prev; + try { + return await fn(); + } finally { + resolve!(); + } + } + + async loadAll(): Promise { + return this.withWriteLock(async () => { + const agentIds = getAllMcpAgentIds(); + const serversByName = new Map }>(); + + for (const agentId of agentIds) { + const meta = getAgentMcpMeta(agentId); + if (!meta) continue; + + let rawServers: ServerMap; + try { + rawServers = await readServers(meta); + } catch (err) { + log.warn(`Failed to read MCP config for ${agentId}:`, err); + continue; + } + + // Reverse-adapt to canonical raw format + const canonical = adaptReverse(meta.adapter, rawServers); + + for (const [name, raw] of Object.entries(canonical)) { + const existing = serversByName.get(name); + if (existing) { + existing.providers.add(agentId); + // Keep richest config (more keys = richer) + const newServer = rawToMcpServer(name, raw, existing.providers); + const existingKeyCount = Object.keys(rawEntryToMcpFields(existing.server)).length; + const newKeyCount = Object.keys(rawEntryToMcpFields(newServer)).length; + if (newKeyCount > existingKeyCount) { + existing.server = newServer; + } + } else { + const providers = new Set([agentId]); + serversByName.set(name, { + server: rawToMcpServer(name, raw, providers), + providers, + }); + } + } + } + + const installed: McpServer[] = []; + for (const { server, providers } of serversByName.values()) { + server.providers = Array.from(providers); + installed.push(server); + } + + const catalog = loadCatalog(); + + return { installed, catalog }; + }); + } + + async saveServer(server: McpServer): Promise { + if (!server.name || !/^[\w\-._]+$/.test(server.name)) { + throw new Error(`Invalid server name: "${server.name}"`); + } + return this.withWriteLock(async () => { + const allAgentIds = getAllMcpAgentIds(); + const selectedProviders = new Set(server.providers); + const raw = mcpServerToRaw(server); + const pendingWrites: PendingWrite[] = []; + const readFailures: string[] = []; + const writeFailures: string[] = []; + + for (const agentId of allAgentIds) { + const meta = getAgentMcpMeta(agentId); + if (!meta) continue; + + let existing: ServerMap; + try { + existing = await readServers(meta); + } catch (err) { + log.error(`Failed to read MCP config for ${agentId}:`, err); + readFailures.push(agentId); + continue; + } + + if (selectedProviders.has(agentId)) { + // Add/update: forward-adapt the single server and merge into existing + const adapted = adaptForward(meta.adapter, { [server.name]: raw }); + const adaptedEntry = adapted[server.name]; + if (!adaptedEntry) continue; + + pendingWrites.push({ + agentId, + meta, + servers: { ...existing, [server.name]: adaptedEntry }, + }); + continue; + } + + if (server.name in existing) { + const next = { ...existing }; + delete next[server.name]; + pendingWrites.push({ agentId, meta, servers: next }); + } + } + + if (readFailures.length) { + throw buildConfigFailureError('read', readFailures); + } + + for (const { agentId, meta, servers } of pendingWrites) { + try { + await writeServers(meta, servers); + } catch (err) { + log.error(`Failed to write MCP config for ${agentId}:`, err); + writeFailures.push(agentId); + } + } + + if (writeFailures.length) { + const successfulWrites = getSuccessfulWriteAgentIds(pendingWrites, writeFailures); + throw buildConfigFailureError('write', writeFailures, successfulWrites); + } + }); + } + + async removeServer(serverName: string): Promise { + return this.withWriteLock(async () => { + const allAgentIds = getAllMcpAgentIds(); + const pendingWrites: PendingWrite[] = []; + const readFailures: string[] = []; + const writeFailures: string[] = []; + + for (const agentId of allAgentIds) { + const meta = getAgentMcpMeta(agentId); + if (!meta) continue; + + let existing: ServerMap; + try { + existing = await readServers(meta); + } catch (err) { + log.error(`Failed to read MCP config for ${agentId}:`, err); + readFailures.push(agentId); + continue; + } + + if (!(serverName in existing)) continue; + + const next = { ...existing }; + delete next[serverName]; + pendingWrites.push({ agentId, meta, servers: next }); + } + + if (readFailures.length) { + throw buildConfigFailureError('read', readFailures); + } + + for (const { agentId, meta, servers } of pendingWrites) { + try { + await writeServers(meta, servers); + } catch (err) { + log.error(`Failed to write MCP config for ${agentId}:`, err); + writeFailures.push(agentId); + } + } + + if (writeFailures.length) { + const successfulWrites = getSuccessfulWriteAgentIds(pendingWrites, writeFailures); + throw buildConfigFailureError('write', writeFailures, successfulWrites); + } + }); + } +} + +type PendingWrite = { + agentId: string; + meta: AgentMcpMeta; + servers: ServerMap; +}; + +function getSuccessfulWriteAgentIds( + pendingWrites: PendingWrite[], + writeFailures: string[] +): string[] { + return pendingWrites + .map(({ agentId }) => agentId) + .filter((agentId) => !writeFailures.includes(agentId)); +} + +function buildConfigFailureError( + action: 'read' | 'write', + failures: string[], + successes: string[] = [] +): Error { + const baseMessage = `Failed to ${action} config for: ${failures.join(', ')}`; + if (action !== 'write' || successes.length === 0) { + return new Error(baseMessage); + } + + return new Error(`${baseMessage}. Updated before failure: ${successes.join(', ')}`); +} + +// ── Conversion helpers ───────────────────────────────────────────────────── + +function rawToMcpServer(name: string, raw: RawServerEntry, providers: Set): McpServer { + const isHttp = raw.type === 'http' || ('url' in raw && !('command' in raw)); + return { + name, + transport: isHttp ? 'http' : 'stdio', + command: typeof raw.command === 'string' ? raw.command : undefined, + args: Array.isArray(raw.args) ? (raw.args as string[]) : undefined, + url: typeof raw.url === 'string' ? raw.url : undefined, + headers: + typeof raw.headers === 'object' && raw.headers !== null + ? (raw.headers as Record) + : undefined, + env: + typeof raw.env === 'object' && raw.env !== null + ? (raw.env as Record) + : undefined, + providers: Array.from(providers), + }; +} + +function mcpServerToRaw(server: McpServer): RawServerEntry { + const raw: RawServerEntry = {}; + if (server.transport === 'http') { + raw.type = 'http'; + if (server.url) raw.url = server.url; + if (server.headers && Object.keys(server.headers).length) raw.headers = server.headers; + } else { + if (server.command) raw.command = server.command; + if (server.args?.length) raw.args = server.args; + } + if (server.env && Object.keys(server.env).length) raw.env = server.env; + return raw; +} + +function rawEntryToMcpFields(server: McpServer): Record { + const fields: Record = {}; + if (server.command) fields.command = server.command; + if (server.args?.length) fields.args = server.args; + if (server.url) fields.url = server.url; + if (server.headers) fields.headers = server.headers; + if (server.env) fields.env = server.env; + return fields; +} + +export const mcpService = new McpService(); diff --git a/src/main/services/OAuthFlowService.ts b/src/main/services/OAuthFlowService.ts new file mode 100644 index 0000000000..caad2a99ff --- /dev/null +++ b/src/main/services/OAuthFlowService.ts @@ -0,0 +1,172 @@ +import * as http from 'http'; +import { randomBytes, createHash } from 'crypto'; +import { net, shell } from 'electron'; +import { GITHUB_CONFIG } from '../config/github.config'; + +const AUTH_TIMEOUT_MS = GITHUB_CONFIG.oauthServer.authTimeoutMs; + +const EMDASH_LOGO_SVG = ``; + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function callbackPage(title: string, description: string): string { + const safeTitle = escapeHtml(title); + const safeDescription = escapeHtml(description); + return ` + +${safeTitle} - Emdash + +${EMDASH_LOGO_SVG.replace('width="499" height="70"', 'class="logo" width="499" height="70"')} +

${safeTitle}

${safeDescription}

+`; +} + +export interface AccountUser { + userId: string; + username: string; + avatarUrl: string; + email: string; +} + +export interface ExchangeResult { + sessionToken: string; + accessToken: string; + providerId: string; + user: AccountUser; +} + +export class OAuthFlowService { + /** + * Run the full OAuth sign-in flow: + * 1. Generate PKCE challenge + * 2. Start loopback server + * 3. Open browser to auth server + * 4. Wait for callback + * 5. Exchange code for tokens + */ + async startFlow(): Promise { + const state = randomBytes(12).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); + + const { code } = await this.startLoopbackServer(state, codeChallenge); + return this.exchangeCode(state, code, codeVerifier); + } + + /** + * Start an ephemeral HTTP server on 127.0.0.1:0 and wait for the OAuth callback. + * Opens the browser to the auth server sign-in page. + */ + private startLoopbackServer( + state: string, + codeChallenge: string + ): Promise<{ code: string; port: number }> { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + const url = new URL(req.url, `http://${req.headers.host}`); + if (url.pathname !== '/callback') { + res.writeHead(404); + res.end('Not found'); + return; + } + + const returnedState = url.searchParams.get('state'); + const code = url.searchParams.get('code'); + + if (returnedState !== state || !code) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end( + callbackPage('Sign-in failed', 'Invalid state or missing code. You can close this tab.') + ); + reject(new Error('State mismatch or missing code in OAuth callback')); + setTimeout(() => server.close(), 1000); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(callbackPage('Success', 'You can close this tab and return to Emdash.')); + + resolve({ code, port: (server.address() as any).port }); + setTimeout(() => server.close(), 2000); + }); + + const timeout = setTimeout(() => { + server.close(); + reject(new Error('OAuth authentication timed out')); + }, AUTH_TIMEOUT_MS); + + server.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + server.on('close', () => { + clearTimeout(timeout); + }); + + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as any).port; + const { baseUrl } = GITHUB_CONFIG.oauthServer; + const redirectUri = `http://127.0.0.1:${port}/callback`; + const signInUrl = `${baseUrl}/sign-in?provider_id=github&state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${encodeURIComponent(codeChallenge)}&code_challenge_method=S256`; + + shell.openExternal(signInUrl).catch((err) => { + clearTimeout(timeout); + server.close(); + reject(new Error(`Failed to open browser: ${err.message}`)); + }); + }); + }); + } + + /** + * Exchange the one-time code with the auth server for tokens and user info. + */ + private async exchangeCode( + state: string, + code: string, + codeVerifier: string + ): Promise { + const { baseUrl } = GITHUB_CONFIG.oauthServer; + const response = await net.fetch(`${baseUrl}/api/v1/auth/electron/exchange`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state, code, code_verifier: codeVerifier }), + signal: AbortSignal.timeout(30_000), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error || `Token exchange failed (${response.status})`); + } + + const data = (await response.json()) as ExchangeResult; + if (!data.sessionToken || !data.accessToken || !data.providerId || !data.user) { + throw new Error('Invalid exchange response'); + } + + return data; + } +} + +export const oauthFlowService = new OAuthFlowService(); diff --git a/src/main/services/OpenCodeHookService.ts b/src/main/services/OpenCodeHookService.ts new file mode 100644 index 0000000000..8cdffe08f0 --- /dev/null +++ b/src/main/services/OpenCodeHookService.ts @@ -0,0 +1,119 @@ +import fs from 'fs'; +import path from 'path'; +import { app } from 'electron'; +import { log } from '../lib/logger'; + +export const OPEN_CODE_PLUGIN_FILE = 'emdash-notify.js'; +const OPEN_CODE_IDLE_MESSAGE = 'OpenCode is ready for your input'; +const OPEN_CODE_PERMISSION_MESSAGE = 'OpenCode is waiting for permission'; + +function sanitizePtySegment(ptyId: string): string { + return ptyId.replace(/[^A-Za-z0-9._-]/g, '-'); +} + +export class OpenCodeHookService { + static getRemoteConfigDirRelative(ptyId: string): string { + return `.config/emdash/agent-hooks/opencode/${sanitizePtySegment(ptyId)}`; + } + + static getLocalConfigDir(ptyId: string): string { + return path.join(app.getPath('userData'), 'agent-hooks', 'opencode', sanitizePtySegment(ptyId)); + } + + static getRemoteConfigDir(ptyId: string): string { + return `$HOME/${OpenCodeHookService.getRemoteConfigDirRelative(ptyId)}`; + } + + static getPluginSource(): string { + return [ + 'const HOOK_PATH = "/hook";', + `const IDLE_MESSAGE = ${JSON.stringify(OPEN_CODE_IDLE_MESSAGE)};`, + `const PERMISSION_MESSAGE = ${JSON.stringify(OPEN_CODE_PERMISSION_MESSAGE)};`, + '', + 'function getHookUrl() {', + ' const port = process.env.EMDASH_HOOK_PORT;', + ' return port ? `http://127.0.0.1:${port}${HOOK_PATH}` : null;', + '}', + '', + 'async function postToEmdash(eventType, payload) {', + ' const url = getHookUrl();', + ' const token = process.env.EMDASH_HOOK_TOKEN;', + ' const ptyId = process.env.EMDASH_PTY_ID;', + ' if (!url || !token || !ptyId) return;', + ' try {', + ' await fetch(url, {', + ' method: "POST",', + ' headers: {', + ' "Content-Type": "application/json",', + ' "X-Emdash-Token": token,', + ' "X-Emdash-Pty-Id": ptyId,', + ' "X-Emdash-Event-Type": eventType,', + ' },', + ' body: JSON.stringify(payload),', + ' });', + ' } catch {}', + '}', + '', + 'function pickErrorMessage(event) {', + ' const candidates = [', + ' event?.message,', + ' event?.properties?.message,', + ' event?.properties?.error?.message,', + ' event?.error?.message,', + ' event?.properties?.detail,', + ' ];', + ' for (const value of candidates) {', + ' if (typeof value === "string" && value.trim()) return value;', + ' }', + ' return "OpenCode session error";', + '}', + '', + 'export const EmdashNotifyPlugin = async () => ({', + ' event: async ({ event }) => {', + ' if (!event?.type) return;', + '', + ' if (event.type === "permission.asked") {', + ' await postToEmdash("notification", {', + ' notificationType: "permission_prompt",', + ' message: PERMISSION_MESSAGE,', + ' });', + ' return;', + ' }', + '', + ' if (event.type === "session.idle") {', + ' await postToEmdash("notification", {', + ' notificationType: "idle_prompt",', + ' message: IDLE_MESSAGE,', + ' });', + ' return;', + ' }', + '', + ' if (event.type === "session.error") {', + ' await postToEmdash("error", {', + ' message: pickErrorMessage(event),', + ' });', + ' }', + ' },', + '});', + '', + ].join('\n'); + } + + static writeLocalPlugin(ptyId: string): string { + const configDir = OpenCodeHookService.getLocalConfigDir(ptyId); + const pluginsDir = path.join(configDir, 'plugins'); + const pluginPath = path.join(pluginsDir, OPEN_CODE_PLUGIN_FILE); + + try { + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync(pluginPath, OpenCodeHookService.getPluginSource()); + } catch (err) { + log.warn('OpenCodeHookService: failed to write local plugin', { + path: pluginPath, + error: String(err), + }); + } + + return configDir; + } +} diff --git a/src/main/services/PlainService.ts b/src/main/services/PlainService.ts new file mode 100644 index 0000000000..0fdb315cee --- /dev/null +++ b/src/main/services/PlainService.ts @@ -0,0 +1,388 @@ +import { request } from 'node:https'; +import { URL } from 'node:url'; +import { app } from 'electron'; +import { join } from 'node:path'; +import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; + +const PLAIN_API_URL = 'https://core-api.uk.plain.com/graphql/v1'; +const REQUEST_TIMEOUT_MS = 15_000; + +const THREAD_FIELDS = ` + id ref title previewText status priority + customer { id fullName email { email } } + labels { labelType { id name } } + updatedAt { iso8601 } +`; + +export interface PlainWorkspace { + id: string; + name: string; +} + +export interface PlainConnectionStatus { + connected: boolean; + workspaceName?: string; + error?: string; +} + +/** Thread shape returned by mapThread — mirrors PlainThreadSummary in renderer types. */ +export interface PlainThread { + id: string; + ref: string | null; + title: string; + description: string | null; + status: string | null; + priority: number | null; + customer: { id: string; fullName: string | null; email: string | null } | null; + labels: Array<{ id: string; name: string | null }> | null; + updatedAt: string | null; + url: string | null; +} + +interface GraphQLResponse { + data?: T; + errors?: Array<{ message: string }>; +} + +export class PlainService { + private readonly SERVICE_NAME = 'emdash-plain'; + private readonly ACCOUNT_NAME = 'api-token'; + + async saveToken( + token: string + ): Promise<{ success: boolean; workspaceName?: string; error?: string }> { + try { + const workspace = await this.fetchWorkspace(token); + await this.storeToken(token); + this.saveWorkspaceId(workspace.id); + void import('../telemetry').then(({ capture }) => { + void capture('plain_connected'); + }); + return { + success: true, + workspaceName: workspace.name ?? undefined, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to validate Plain token. Please try again.'; + return { success: false, error: message }; + } + } + + async clearToken(): Promise<{ success: boolean; error?: string }> { + try { + const keytar = await import('keytar'); + await keytar.deletePassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + this.clearWorkspaceId(); + void import('../telemetry').then(({ capture }) => { + void capture('plain_disconnected'); + }); + return { success: true }; + } catch (error) { + console.error('Failed to clear Plain token:', error); + return { + success: false, + error: 'Unable to remove Plain token from keychain.', + }; + } + } + + async checkConnection(): Promise { + try { + const token = await this.getStoredToken(); + if (!token) { + return { connected: false }; + } + + const workspace = await this.fetchWorkspace(token); + return { + connected: true, + workspaceName: workspace.name ?? undefined, + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to verify Plain connection.'; + return { connected: false, error: message }; + } + } + + async initialFetch(limit = 50, statuses?: string[]): Promise { + const token = await this.getStoredToken(); + if (!token) { + throw new Error('Plain token not set. Connect Plain in settings first.'); + } + + const sanitizedLimit = Math.min(Math.max(limit, 1), 200); + const workspaceId = this.loadWorkspaceId(); + + const hasStatuses = statuses && statuses.length > 0; + + const query = hasStatuses + ? ` + query ListThreads($first: Int!, $statuses: [ThreadStatus!]!) { + threads(first: $first, filters: { statuses: $statuses }, sortBy: { field: CREATED_AT, direction: DESC }) { + edges { node { ${THREAD_FIELDS} } } + } + } + ` + : ` + query ListThreads($first: Int!) { + threads(first: $first, sortBy: { field: CREATED_AT, direction: DESC }) { + edges { node { ${THREAD_FIELDS} } } + } + } + `; + + const variables: Record = { first: sanitizedLimit }; + if (hasStatuses) variables.statuses = statuses; + + const response = await this.graphql<{ + threads: { edges: Array<{ node: any }> }; + }>(token, query, variables); + + const threads = (response?.threads?.edges ?? []).map((edge) => + this.mapThread(edge.node, workspaceId) + ); + + return threads; + } + + async searchThreads(searchTerm: string, limit = 20): Promise { + const token = await this.getStoredToken(); + if (!token) { + throw new Error('Plain token not set. Connect Plain in settings first.'); + } + + const trimmed = searchTerm.trim(); + if (!trimmed) { + return []; + } + + const workspaceId = this.loadWorkspaceId(); + const isRefSearch = /^T-\d+$/i.test(trimmed); + + try { + if (isRefSearch) { + return await this.fetchThreadByRef(token, trimmed.toUpperCase(), workspaceId); + } + + // Plain has no server-side text search — fetch a wide batch and filter client-side + const fetchSize = 200; + const resultLimit = Math.min(Math.max(limit, 1), 200); + const query = ` + query SearchThreads($first: Int!) { + threads(first: $first, sortBy: { field: CREATED_AT, direction: DESC }) { + edges { node { ${THREAD_FIELDS} } } + } + } + `; + + const response = await this.graphql<{ + threads: { edges: Array<{ node: any }> }; + }>(token, query, { first: fetchSize }); + + const lowerTerm = trimmed.toLowerCase(); + const threads = (response?.threads?.edges ?? []) + .map((edge) => this.mapThread(edge.node, workspaceId)) + .filter( + (t) => + (t.title && t.title.toLowerCase().includes(lowerTerm)) || + (t.ref && t.ref.toLowerCase().includes(lowerTerm)) || + (t.customer?.fullName && t.customer.fullName.toLowerCase().includes(lowerTerm)) || + (t.customer?.email && t.customer.email.toLowerCase().includes(lowerTerm)) + ) + .slice(0, resultLimit); + + return threads; + } catch (error) { + console.error('[Plain] searchThreads error:', error); + return []; + } + } + + private async fetchThreadByRef( + token: string, + ref: string, + workspaceId: string | null + ): Promise { + const query = ` + query GetThreadByRef($ref: String!) { + threadByRef(ref: $ref) { ${THREAD_FIELDS} } + } + `; + + try { + const response = await this.graphql<{ threadByRef: any | null }>(token, query, { ref }); + if (!response?.threadByRef) return []; + return [this.mapThread(response.threadByRef, workspaceId)]; + } catch { + return []; + } + } + + private mapThread(node: any, workspaceId: string | null): PlainThread { + return { + id: node.id, + ref: node.ref ?? null, + title: node.title ?? '', + description: node.previewText ?? node.description ?? null, + status: node.status ?? null, + priority: node.priority ?? null, + customer: node.customer + ? { + id: node.customer.id, + fullName: node.customer.fullName ?? null, + email: node.customer.email?.email ?? null, + } + : null, + labels: Array.isArray(node.labels) + ? node.labels.map((l: any) => ({ + id: l.labelType?.id ?? l.id, + name: l.labelType?.name ?? l.name ?? null, + })) + : null, + updatedAt: node.updatedAt?.iso8601 ?? null, + url: workspaceId ? `https://app.plain.com/workspace/${workspaceId}/t/${node.id}` : null, + }; + } + + private async fetchWorkspace(token: string): Promise { + const query = ` + query WorkspaceInfo { + myWorkspace { + id + name + } + } + `; + + const data = await this.graphql<{ myWorkspace: PlainWorkspace }>(token, query); + if (!data?.myWorkspace) { + throw new Error('Unable to retrieve Plain workspace information.'); + } + return data.myWorkspace; + } + + private saveWorkspaceId(workspaceId: string): void { + try { + const filePath = join(app.getPath('userData'), 'plain.json'); + writeFileSync(filePath, JSON.stringify({ workspaceId }), 'utf-8'); + } catch (error) { + console.error('Failed to save Plain workspace ID:', error); + } + } + + private loadWorkspaceId(): string | null { + try { + const filePath = join(app.getPath('userData'), 'plain.json'); + const data = JSON.parse(readFileSync(filePath, 'utf-8')); + return data?.workspaceId ?? null; + } catch { + return null; + } + } + + private clearWorkspaceId(): void { + try { + unlinkSync(join(app.getPath('userData'), 'plain.json')); + } catch { + // file may not exist + } + } + + private async graphql( + token: string, + query: string, + variables?: Record + ): Promise { + const body = JSON.stringify({ query, variables }); + + const requestPromise = new Promise>((resolve, reject) => { + const url = new URL(PLAIN_API_URL); + + const req = request( + { + hostname: url.hostname, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Length': Buffer.byteLength(body).toString(), + }, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error('Invalid API key. Please check your Plain API key and try again.')); + return; + } + try { + const parsed = JSON.parse(data) as GraphQLResponse; + resolve(parsed); + } catch (error) { + reject(error); + } + }); + } + ); + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error('Plain API request timed out.')); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.write(body); + req.end(); + }); + + const result = await requestPromise; + + if (result.errors?.length) { + throw new Error(result.errors.map((err) => err.message).join('\n')); + } + + if (!result.data) { + throw new Error('Plain API returned no data.'); + } + + return result.data; + } + + private async storeToken(token: string): Promise { + const clean = token.trim(); + if (!clean) { + throw new Error('Plain token cannot be empty.'); + } + + try { + const keytar = await import('keytar'); + await keytar.setPassword(this.SERVICE_NAME, this.ACCOUNT_NAME, clean); + } catch (error) { + console.error('Failed to store Plain token:', error); + throw new Error('Unable to store Plain token securely.'); + } + } + + private async getStoredToken(): Promise { + try { + const keytar = await import('keytar'); + return await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + } catch (error) { + console.error('Failed to read Plain token from keychain:', error); + return null; + } + } +} + +export default PlainService; diff --git a/src/main/services/PrGenerationService.ts b/src/main/services/PrGenerationService.ts index 448f68a977..419224be60 100644 --- a/src/main/services/PrGenerationService.ts +++ b/src/main/services/PrGenerationService.ts @@ -2,6 +2,7 @@ import { execFile, spawn } from 'child_process'; import { promisify } from 'util'; import { log } from '../lib/logger'; import { getProvider, PROVIDER_IDS, type ProviderId } from '../../shared/providers/registry'; +import { stripAnsi } from '@shared/text/stripAnsi'; const execFileAsync = promisify(execFile); @@ -83,21 +84,21 @@ export class PrGenerationService { let changedFiles: string[] = []; try { - // Fetch remote to ensure we have latest state (prevents comparing against stale local branches) - // This is critical: if local main is behind remote, we'd incorrectly include others' commits - // Only fetch if remote exists - try { - await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd: taskPath }); - // Remote exists, try to fetch + // Fetch remote in parallel with checking if the base branch ref exists locally. + // The fetch ensures we compare against the latest remote state; the parallel + // rev-parse lets us fall back immediately if the ref is already available. + const fetchPromise = (async () => { try { - await execFileAsync('git', ['fetch', 'origin', '--quiet'], { cwd: taskPath }); - } catch (fetchError) { - log.debug('Failed to fetch remote, continuing with existing refs', { fetchError }); + await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd: taskPath }); + try { + await execFileAsync('git', ['fetch', 'origin', '--quiet'], { cwd: taskPath }); + } catch (fetchError) { + log.debug('Failed to fetch remote, continuing with existing refs', { fetchError }); + } + } catch { + log.debug('Remote origin not found, skipping fetch'); } - } catch { - // Remote doesn't exist, skip fetch - log.debug('Remote origin not found, skipping fetch'); - } + })(); // Always prefer remote branch to avoid stale local branch issues let baseBranchRef = baseBranch; @@ -121,6 +122,9 @@ export class PrGenerationService { } } + // Wait for fetch to complete before computing diffs (ensures up-to-date refs) + await fetchPromise; + if (baseBranchExists) { // Get diff between base branch and current HEAD (committed changes) try { @@ -432,18 +436,6 @@ Respond with ONLY valid JSON — no markdown fences, no preamble, no explanation }`; } - /** - * Strip ANSI escape sequences from a string - */ - private stripAnsi(text: string): string { - // Covers CSI sequences, OSC sequences, and other common escape codes - // eslint-disable-next-line no-control-regex - return text.replace( - /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\x1b[^[(\x1b]*?[a-zA-Z]/g, - '' - ); - } - /** * Parse provider response into PR content. * @@ -457,7 +449,7 @@ Respond with ONLY valid JSON — no markdown fences, no preamble, no explanation private parseProviderResponse(response: string): GeneratedPrContent | null { try { // Step 1: Strip ANSI escape sequences - let text = this.stripAnsi(response); + let text = stripAnsi(response, { stripOscSt: true, stripOtherEscapes: true }); // Step 2: If this is a Claude --output-format json envelope, extract the result field try { diff --git a/src/main/services/RemoteGitService.ts b/src/main/services/RemoteGitService.ts index a45a2ebf8f..552640fc56 100644 --- a/src/main/services/RemoteGitService.ts +++ b/src/main/services/RemoteGitService.ts @@ -1,40 +1,74 @@ import { SshService } from './ssh/SshService'; import type { ExecResult } from '../../shared/ssh/types'; import { quoteShellArg } from '../utils/shellEscape'; -import type { GitChange } from './GitService'; -import { parseDiffLines, stripTrailingNewline, MAX_DIFF_CONTENT_BYTES } from '../utils/diffParser'; -import type { DiffLine, DiffResult } from '../utils/diffParser'; - -export interface WorktreeInfo { - path: string; - branch: string; - isMain: boolean; -} - -export interface GitStatusFile { - status: string; - path: string; -} - -export interface GitStatus { - branch: string; - isClean: boolean; - files: GitStatusFile[]; -} +import { parseDiffLines, MAX_DIFF_CONTENT_BYTES, MAX_DIFF_OUTPUT_BYTES } from '../utils/diffParser'; +import { parseGitStatusOutput, parseNumstatOutput } from '../utils/gitStatusParser'; +import type { DiffResult } from '../utils/diffParser'; +import { updateIndexShared } from './git-core/indexShared'; +import { parseTaggedRemoteContent } from './git-core/remoteTaggedContent'; +import { revertFileShared } from './git-core/revertShared'; +import { + applyUntrackedLineCounts, + buildStatusChanges, + MAX_UNTRACKED_LINECOUNT_BYTES, +} from './git-core/statusShared'; +import { resolveWorkingTreeDiffResult } from './git-core/workingTreeDiffShared'; +import type { + GitChange, + GitIndexUpdateArgs, + GitStatus, + WorktreeInfo, +} from '../../shared/git/types'; + +export type { GitStatus, WorktreeInfo } from '../../shared/git/types'; export class RemoteGitService { constructor(private sshService: SshService) {} + private static readonly FORCE_LOAD_DIFF_CONTENT_BYTES = 5 * 1024 * 1024; + private static readonly FORCE_LOAD_DIFF_OUTPUT_BYTES = 30 * 1024 * 1024; private normalizeRemotePath(p: string): string { // Remote paths should use forward slashes. return p.replace(/\\/g, '/').replace(/\/+$/g, ''); } + private ensureSafeRelativeFilePath(filePath: string): string { + const normalized = filePath + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/\/+/g, '/'); + if (!normalized || normalized === '.' || normalized.includes('\0')) { + throw new Error('Invalid file path'); + } + if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) { + throw new Error('File path is outside the worktree'); + } + if (normalized.split('/').includes('..')) { + throw new Error('File path is outside the worktree'); + } + return normalized; + } + + private async resolveReviewBaseRef( + connectionId: string, + cwd: string, + baseRef: string + ): Promise { + const mergeBaseResult = await this.sshService.executeCommand( + connectionId, + `git merge-base ${quoteShellArg(baseRef)} HEAD`, + cwd + ); + const mergeBase = (mergeBaseResult.stdout || '').trim(); + return mergeBaseResult.exitCode === 0 && mergeBase ? mergeBase : baseRef; + } + async getStatus(connectionId: string, worktreePath: string): Promise { + const cwd = this.normalizeRemotePath(worktreePath); const result = await this.sshService.executeCommand( connectionId, 'git status --porcelain -b', - worktreePath + cwd ); if (result.exitCode !== 0) { @@ -104,8 +138,12 @@ export class RemoteGitService { .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); - const worktreeName = `${slug || 'task'}-${Date.now()}`; - const relWorktreePath = `.emdash/worktrees/${worktreeName}`; + const { getAppSettings } = await import('../settings'); + const settings = getAppSettings(); + const branchPrefix = settings?.repository?.branchPrefix || 'emdash'; + const dirName = `${slug || 'task'}-${Date.now()}`; + const worktreeName = `${branchPrefix}/${dirName}`; + const relWorktreePath = `.emdash/worktrees/${dirName}`; const worktreePath = `${normalizedProjectPath}/${relWorktreePath}`.replace(/\/+/g, '/'); // Create worktrees directory (relative so we avoid quoting issues) @@ -235,22 +273,18 @@ export class RemoteGitService { const stagedFiles: string[] = []; const unstagedFiles: string[] = []; const untrackedFiles: string[] = []; - const lines = (result.stdout || '') - .trim() - .split('\n') - .filter((l) => l.length > 0); + const entries = parseGitStatusOutput(result.stdout || ''); - for (const line of lines) { - const status = line.substring(0, 2); - const file = line.substring(3); - if (status.includes('A') || status.includes('M') || status.includes('D')) { - stagedFiles.push(file); + for (const entry of entries) { + if (entry.isStaged) { + stagedFiles.push(entry.path); } - if (status[1] === 'M' || status[1] === 'D') { - unstagedFiles.push(file); + const worktreeStatus = entry.statusCode.padEnd(2, '.')[1]; + if (worktreeStatus !== '.' && worktreeStatus !== ' ' && worktreeStatus !== '?') { + unstagedFiles.push(entry.path); } - if (status.includes('??')) { - untrackedFiles.push(file); + if (entry.statusCode.includes('?')) { + untrackedFiles.push(entry.path); } } @@ -308,7 +342,6 @@ export class RemoteGitService { */ async getStatusDetailed(connectionId: string, worktreePath: string): Promise { const cwd = this.normalizeRemotePath(worktreePath); - // Verify git repo const verifyResult = await this.sshService.executeCommand( connectionId, @@ -319,23 +352,31 @@ export class RemoteGitService { return []; } - // Get porcelain status - const statusResult = await this.sshService.executeCommand( + let statusOutput = ''; + const statusV2Result = await this.sshService.executeCommand( connectionId, - 'git status --porcelain --untracked-files=all', + 'git status --porcelain=v2 -z --untracked-files=all', cwd ); - if (statusResult.exitCode !== 0) { - throw new Error(`Git status failed: ${statusResult.stderr}`); + if (statusV2Result.exitCode === 0) { + statusOutput = statusV2Result.stdout || ''; + } else { + // Fallback for older remote git versions. + const statusV1Result = await this.sshService.executeCommand( + connectionId, + 'git status --porcelain --untracked-files=all', + cwd + ); + if (statusV1Result.exitCode !== 0) { + const stderr = (statusV1Result.stderr || statusV2Result.stderr || '').trim(); + throw new Error(stderr || 'Failed to read git status'); + } + statusOutput = statusV1Result.stdout || ''; } - const statusOutput = statusResult.stdout; if (!statusOutput.trim()) return []; - const statusLines = statusOutput - .split('\n') - .map((l) => l.replace(/\r$/, '')) - .filter((l) => l.length > 0); + const entries = parseGitStatusOutput(statusOutput); // Batch-fetch numstat for staged and unstaged changes (one SSH call each, not per-file) const [stagedNumstat, unstagedNumstat] = await Promise.all([ @@ -343,185 +384,251 @@ export class RemoteGitService { this.sshService.executeCommand(connectionId, 'git diff --numstat', cwd), ]); - const parseNumstat = (stdout: string): Map => { - const map = new Map(); - for (const line of stdout.split('\n').filter((l) => l.trim())) { - const parts = line.split('\t'); - if (parts.length >= 3) { - const add = parts[0] === '-' ? 0 : parseInt(parts[0], 10) || 0; - const del = parts[1] === '-' ? 0 : parseInt(parts[1], 10) || 0; - map.set(parts[2], { add, del }); - } - } - return map; - }; - - const stagedStats = parseNumstat(stagedNumstat.stdout || ''); - const unstagedStats = parseNumstat(unstagedNumstat.stdout || ''); - - // Collect untracked file paths so we can batch their line counts - const untrackedPaths: string[] = []; - - const changes: GitChange[] = []; - for (const line of statusLines) { - const statusCode = line.substring(0, 2); - let filePath = line.substring(3); - if (statusCode.includes('R') && filePath.includes('->')) { - const parts = filePath.split('->'); - filePath = parts[parts.length - 1].trim(); - } - - let status = 'modified'; - if (statusCode.includes('A') || statusCode.includes('?')) status = 'added'; - else if (statusCode.includes('D')) status = 'deleted'; - else if (statusCode.includes('R')) status = 'renamed'; - else if (statusCode.includes('M')) status = 'modified'; - - const isStaged = statusCode[0] !== ' ' && statusCode[0] !== '?'; - - const staged = stagedStats.get(filePath); - const unstaged = unstagedStats.get(filePath); - const additions = (staged?.add ?? 0) + (unstaged?.add ?? 0); - const deletions = (staged?.del ?? 0) + (unstaged?.del ?? 0); - - if (additions === 0 && deletions === 0 && statusCode.includes('?')) { - untrackedPaths.push(filePath); - } - - changes.push({ path: filePath, status, additions, deletions, isStaged }); - } + const stagedStats = parseNumstatOutput(stagedNumstat.stdout || ''); + const unstagedStats = parseNumstatOutput(unstagedNumstat.stdout || ''); + const { changes, untrackedPathsNeedingCounts } = buildStatusChanges( + entries, + stagedStats, + unstagedStats + ); // Batch line-count for untracked files (skip files > 512KB) - if (untrackedPaths.length > 0) { - const escaped = untrackedPaths.map((f) => quoteShellArg(f)).join(' '); - // For each file: if <= 512KB, count newlines; otherwise print -1 - const script = - `for f in ${escaped}; do ` + - `s=$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null); ` + - `if [ "$s" -le ${MAX_DIFF_CONTENT_BYTES} ] 2>/dev/null; then ` + - `wc -l < "$f" 2>/dev/null || echo -1; ` + - `else echo -1; fi; done`; - const countResult = await this.sshService.executeCommand(connectionId, script, cwd); - if (countResult.exitCode === 0) { - const counts = countResult.stdout - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0); - for (let i = 0; i < untrackedPaths.length && i < counts.length; i++) { - const count = parseInt(counts[i], 10); - if (count >= 0) { - const change = changes.find((c) => c.path === untrackedPaths[i]); - if (change) change.additions = count; - } - } + if (untrackedPathsNeedingCounts.length === 0) { + return changes; + } + + const escaped = untrackedPathsNeedingCounts + .map((filePath) => quoteShellArg(filePath)) + .join(' '); + // For each file: if <= 512KB, count newlines; otherwise print -1 + const script = + `for f in ${escaped}; do ` + + `s=$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null); ` + + `if [ "$s" -le ${MAX_UNTRACKED_LINECOUNT_BYTES} ] 2>/dev/null; then ` + + `wc -l < "$f" 2>/dev/null || echo -1; ` + + `else echo -1; fi; done`; + const countResult = await this.sshService.executeCommand(connectionId, script, cwd); + if (countResult.exitCode !== 0) { + return changes; + } + + const parsedCounts = (countResult.stdout || '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const untrackedCountByPath = new Map(); + for (let i = 0; i < untrackedPathsNeedingCounts.length; i++) { + const countValue = parsedCounts[i]; + if (countValue === undefined) { + untrackedCountByPath.set(untrackedPathsNeedingCounts[i], null); + continue; } + const count = Number.parseInt(countValue, 10); + untrackedCountByPath.set( + untrackedPathsNeedingCounts[i], + Number.isFinite(count) && count >= 0 ? count : null + ); } - return changes; + return applyUntrackedLineCounts(changes, untrackedCountByPath); } /** * Per-file diff matching the shape returned by local GitService.getFileDiff(). - * Uses a diff-first pattern: run git diff, check for binary, then fetch content only if non-binary. */ async getFileDiff( connectionId: string, worktreePath: string, - filePath: string + filePath: string, + baseRef?: string, + forceLarge?: boolean ): Promise { const cwd = this.normalizeRemotePath(worktreePath); - - // Step 1: Run git diff - const diffResult = await this.sshService.executeCommand( - connectionId, - `git diff --no-color --unified=2000 HEAD -- ${quoteShellArg(filePath)}`, - cwd - ); - - // Step 2: Parse and check binary - let diffLines: DiffLine[] = []; - if (diffResult.exitCode === 0 && diffResult.stdout.trim()) { - const { lines, isBinary } = parseDiffLines(diffResult.stdout); - if (isBinary) { - return { lines: [], isBinary: true }; - } - diffLines = lines; - } - - // Step 3: Fetch content ONCE (non-binary only, covers both diff-success and fallback paths) - const [showResult, catResult] = await Promise.all([ - this.sshService.executeCommand( + const safeFilePath = this.ensureSafeRelativeFilePath(filePath); + const diffContentLimit = forceLarge + ? RemoteGitService.FORCE_LOAD_DIFF_CONTENT_BYTES + : MAX_DIFF_CONTENT_BYTES; + const diffOutputLimit = forceLarge + ? RemoteGitService.FORCE_LOAD_DIFF_OUTPUT_BYTES + : MAX_DIFF_OUTPUT_BYTES; + const reviewBaseRef = baseRef + ? await this.resolveReviewBaseRef(connectionId, cwd, baseRef) + : undefined; + const originalRef = reviewBaseRef || 'HEAD'; + + const readGitObjectTextCapped = async (objectSpec: string) => { + const result = await this.sshService.executeCommand( connectionId, - `s=$(git cat-file -s HEAD:${quoteShellArg(filePath)} 2>/dev/null); ` + - `if [ "$s" -le ${MAX_DIFF_CONTENT_BYTES} ] 2>/dev/null; then git show HEAD:${quoteShellArg(filePath)}; ` + - `else echo "__EMDASH_TOO_LARGE__"; fi`, + `if git cat-file -e ${quoteShellArg(objectSpec)} 2>/dev/null; then ` + + `s=$(git cat-file -s ${quoteShellArg(objectSpec)} 2>/dev/null); ` + + `if [ "$s" -le ${diffContentLimit} ] 2>/dev/null; then ` + + `printf "__EMDASH_CONTENT__\\n"; git show ${quoteShellArg(objectSpec)}; ` + + `else echo "__EMDASH_TOO_LARGE__"; fi; ` + + `else echo "__EMDASH_MISSING__"; fi`, cwd - ), - this.sshService.executeCommand( + ); + return parseTaggedRemoteContent(result); + }; + + const readWorkingFileTextCapped = async (remoteFilePath: string) => { + const result = await this.sshService.executeCommand( connectionId, - `s=$(stat -c%s ${quoteShellArg(filePath)} 2>/dev/null || stat -f%z ${quoteShellArg(filePath)} 2>/dev/null); ` + - `if [ "$s" -le ${MAX_DIFF_CONTENT_BYTES} ] 2>/dev/null; then cat ${quoteShellArg(filePath)}; else echo "__EMDASH_TOO_LARGE__"; fi`, + `if [ -f ${quoteShellArg(remoteFilePath)} ]; then ` + + `s=$(stat -c%s ${quoteShellArg(remoteFilePath)} 2>/dev/null || stat -f%z ${quoteShellArg(remoteFilePath)} 2>/dev/null); ` + + `if [ "$s" -le ${diffContentLimit} ] 2>/dev/null; then ` + + `printf "__EMDASH_CONTENT__\\n"; cat ${quoteShellArg(remoteFilePath)}; ` + + `else echo "__EMDASH_TOO_LARGE__"; fi; ` + + `else echo "__EMDASH_MISSING__"; fi`, cwd - ), - ]); + ); + return parseTaggedRemoteContent(result); + }; - const rawOriginal = - showResult.exitCode === 0 ? stripTrailingNewline(showResult.stdout) : undefined; - const originalContent = rawOriginal === '__EMDASH_TOO_LARGE__' ? undefined : rawOriginal; + const getOriginalContent = async () => { + return readGitObjectTextCapped(`${originalRef}:${safeFilePath}`); + }; - const rawModified = - catResult.exitCode === 0 ? stripTrailingNewline(catResult.stdout) : undefined; - const modifiedContent = rawModified === '__EMDASH_TOO_LARGE__' ? undefined : rawModified; + const getModifiedContent = async () => { + if (baseRef) { + return readGitObjectTextCapped(`HEAD:${safeFilePath}`); + } + return readWorkingFileTextCapped(safeFilePath); + }; - // Step 4: Return based on what we have - if (diffLines.length > 0) return { lines: diffLines, originalContent, modifiedContent }; + const [original, modified] = await Promise.all([getOriginalContent(), getModifiedContent()]); - // Fallback: empty diff or diff failed — determine untracked/deleted from content - if (modifiedContent !== undefined) { - return { - lines: modifiedContent.split('\n').map((l) => ({ right: l, type: 'add' as const })), - modifiedContent, - }; + // Fast path: if content probe already indicates binary/oversized file, skip full git diff. + if (original.isBinary || modified.isBinary) { + return { lines: [], mode: 'binary', isBinary: true }; } - if (originalContent !== undefined) { - return { - lines: originalContent.split('\n').map((l) => ({ left: l, type: 'del' as const })), - originalContent, - }; + if (original.tooLarge || modified.tooLarge) { + return resolveWorkingTreeDiffResult({ + diffStdout: undefined, + diffLines: [], + hasHunk: false, + diffTooLarge: true, + diffFailed: false, + original, + modified, + }); } - return { lines: [] }; - } - async stageFile(connectionId: string, worktreePath: string, filePath: string): Promise { - const cwd = this.normalizeRemotePath(worktreePath); - const result = await this.sshService.executeCommand( - connectionId, - `git add -- ${quoteShellArg(filePath)}`, - cwd - ); - if (result.exitCode !== 0) { - throw new Error(`Failed to stage file: ${result.stderr}`); - } - } + // Step 1: Run git diff + let diffStdout: string | undefined; + let diffTooLarge = false; + let diffFailed = false; + let diffLines: DiffResult['lines'] = []; + let hasHunk = false; + const diffCommand = baseRef + ? `git diff --no-color --unified=2000 ${quoteShellArg(originalRef)} HEAD -- ${quoteShellArg(safeFilePath)}` + : `git diff --no-color --unified=2000 HEAD -- ${quoteShellArg(safeFilePath)}`; + const diffResult = await this.sshService.executeCommand(connectionId, diffCommand, cwd); + if (diffResult.exitCode === 0) { + diffStdout = diffResult.stdout || ''; + diffTooLarge = Buffer.byteLength(diffStdout, 'utf8') > diffOutputLimit; + + if (diffStdout.trim()) { + const likelyBinary = + diffStdout.includes('Binary files ') || diffStdout.includes('GIT binary patch'); + if (likelyBinary) { + return { lines: [], mode: 'binary', isBinary: true }; + } - async stageAllFiles(connectionId: string, worktreePath: string): Promise { - const cwd = this.normalizeRemotePath(worktreePath); - const result = await this.sshService.executeCommand(connectionId, 'git add -A', cwd); - if (result.exitCode !== 0) { - throw new Error(`Failed to stage all files: ${result.stderr}`); - } + if (!diffTooLarge) { + const parsed = parseDiffLines(diffStdout); + if (parsed.isBinary) { + return { lines: [], mode: 'binary', isBinary: true }; + } + diffLines = parsed.lines; + hasHunk = parsed.hasHunk; + } + } + } else { + diffFailed = true; + } + + return resolveWorkingTreeDiffResult({ + diffStdout, + diffLines, + hasHunk, + diffTooLarge, + diffFailed, + original, + modified, + }); } - async unstageFile(connectionId: string, worktreePath: string, filePath: string): Promise { + async updateIndex( + connectionId: string, + worktreePath: string, + args: GitIndexUpdateArgs + ): Promise { const cwd = this.normalizeRemotePath(worktreePath); - const result = await this.sshService.executeCommand( - connectionId, - `git reset HEAD -- ${quoteShellArg(filePath)}`, - cwd - ); - if (result.exitCode !== 0) { - throw new Error(`Failed to unstage file: ${result.stderr}`); - } + await updateIndexShared(args, { + stageAll: async () => { + const result = await this.sshService.executeCommand(connectionId, 'git add -A', cwd); + if (result.exitCode !== 0) { + throw new Error(`Failed to stage all files: ${result.stderr}`); + } + }, + resetAll: async () => { + const result = await this.sshService.executeCommand( + connectionId, + 'git reset HEAD -- .', + cwd + ); + return result.exitCode === 0; + }, + listStagedPaths: async () => { + const stagedResult = await this.sshService.executeCommand( + connectionId, + 'git diff --cached --name-only', + cwd + ); + return (stagedResult.stdout || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + }, + stagePaths: async (filePaths) => { + const files = filePaths.map((filePath) => quoteShellArg(filePath)).join(' '); + const result = await this.sshService.executeCommand( + connectionId, + `git add -- ${files}`, + cwd + ); + if (result.exitCode !== 0) { + throw new Error(`Failed to stage files: ${result.stderr}`); + } + }, + resetPaths: async (filePaths) => { + const files = filePaths.map((filePath) => quoteShellArg(filePath)).join(' '); + const result = await this.sshService.executeCommand( + connectionId, + `git reset HEAD -- ${files}`, + cwd + ); + return result.exitCode === 0; + }, + resetPath: async (filePath) => { + const result = await this.sshService.executeCommand( + connectionId, + `git reset HEAD -- ${quoteShellArg(filePath)}`, + cwd + ); + return result.exitCode === 0; + }, + removePathFromIndex: async (filePath) => { + const fallback = await this.sshService.executeCommand( + connectionId, + `git rm --cached -- ${quoteShellArg(filePath)}`, + cwd + ); + if (fallback.exitCode !== 0) { + throw new Error(`Failed to unstage file: ${fallback.stderr}`); + } + }, + }); } async revertFile( @@ -530,34 +637,34 @@ export class RemoteGitService { filePath: string ): Promise<{ action: 'reverted' }> { const cwd = this.normalizeRemotePath(worktreePath); - - // Check if file exists in HEAD - const catFileResult = await this.sshService.executeCommand( - connectionId, - `git cat-file -e HEAD:${quoteShellArg(filePath)}`, - cwd - ); - - if (catFileResult.exitCode !== 0) { - // File doesn't exist in HEAD — it's untracked. Delete it. - await this.sshService.executeCommand( - connectionId, - `rm -f -- ${quoteShellArg(filePath)}`, - cwd - ); - return { action: 'reverted' }; - } - - // File exists in HEAD — revert it - const checkoutResult = await this.sshService.executeCommand( - connectionId, - `git checkout HEAD -- ${quoteShellArg(filePath)}`, - cwd - ); - if (checkoutResult.exitCode !== 0) { - throw new Error(`Failed to revert file: ${checkoutResult.stderr}`); - } - return { action: 'reverted' }; + return revertFileShared(filePath, { + normalizeFilePath: (pathInput) => this.ensureSafeRelativeFilePath(pathInput), + existsInHead: async (safePath) => { + const catFileResult = await this.sshService.executeCommand( + connectionId, + `git cat-file -e HEAD:${quoteShellArg(safePath)}`, + cwd + ); + return catFileResult.exitCode === 0; + }, + deleteUntracked: async (safePath) => { + await this.sshService.executeCommand( + connectionId, + `rm -f -- ${quoteShellArg(safePath)}`, + cwd + ); + }, + checkoutHead: async (safePath) => { + const checkoutResult = await this.sshService.executeCommand( + connectionId, + `git checkout HEAD -- ${quoteShellArg(safePath)}`, + cwd + ); + if (checkoutResult.exitCode !== 0) { + throw new Error(`Failed to revert file: ${checkoutResult.stderr}`); + } + }, + }); } // --------------------------------------------------------------------------- @@ -597,7 +704,11 @@ export class RemoteGitService { 'git remote show origin 2>/dev/null | sed -n "/HEAD branch/s/.*: //p"', cwd ); - if (remoteResult.exitCode === 0 && remoteResult.stdout.trim()) { + if ( + remoteResult.exitCode === 0 && + remoteResult.stdout.trim() && + remoteResult.stdout.trim() !== '(unknown)' + ) { return remoteResult.stdout.trim(); } @@ -652,9 +763,10 @@ export class RemoteGitService { let ahead = 0; let behind = 0; + const compareRef = `origin/${defaultBranch}...HEAD`; const revListResult = await this.sshService.executeCommand( connectionId, - `git rev-list --left-right --count origin/${quoteShellArg(defaultBranch)}...HEAD 2>/dev/null`, + `git rev-list --left-right --count ${quoteShellArg(compareRef)} 2>/dev/null`, cwd ); if (revListResult.exitCode === 0) { diff --git a/src/main/services/RemotePtyService.ts b/src/main/services/RemotePtyService.ts index 854f25c8c7..1599e1f797 100644 --- a/src/main/services/RemotePtyService.ts +++ b/src/main/services/RemotePtyService.ts @@ -1,6 +1,8 @@ import { EventEmitter } from 'events'; import { SshService } from './ssh/SshService'; import { quoteShellArg, isValidEnvVarName } from '../utils/shellEscape'; +import { waitForShellPrompt, PromptWaitHandle } from '../utils/waitForShellPrompt'; +import { log } from '../lib/logger'; export interface RemotePtyOptions { id: string; @@ -50,6 +52,7 @@ const ALLOWED_SHELLS = new Set([ */ export class RemotePtyService extends EventEmitter { private ptys: Map = new Map(); + private promptHandles: Map = new Map(); constructor(private sshService: SshService) { super(); @@ -81,7 +84,7 @@ export class RemotePtyService extends EventEmitter { // Validate env var keys to prevent injection (CRITICAL #1) const envEntries = Object.entries(options.env || {}).filter(([k]) => { if (!isValidEnvVarName(k)) { - console.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`); + log.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`); return false; } return true; @@ -94,6 +97,7 @@ export class RemotePtyService extends EventEmitter { // Validate shell against allowlist (HIGH #5) const shellBinary = options.shell.split(/\s+/)[0]; if (!ALLOWED_SHELLS.has(shellBinary)) { + stream.close(); reject( new Error( `Shell not allowed: ${shellBinary}. Allowed: ${[...ALLOWED_SHELLS].join(', ')}` @@ -106,15 +110,49 @@ export class RemotePtyService extends EventEmitter { .filter(Boolean) .join(' && '); - // Send initial command - stream.write(fullCommand + '\n'); + const sshSubscribe = (cb: (chunk: string) => void) => { + const handler = (data: Buffer) => cb(data.toString()); + stream.on('data', handler); + return () => { + stream.removeListener('data', handler); + }; + }; - // Send initial prompt if provided - if (options.initialPrompt) { - setTimeout(() => { - stream.write(options.initialPrompt + '\n'); - }, 500); - } + const handles: PromptWaitHandle[] = []; + this.promptHandles.set(options.id, handles); + + handles.push( + waitForShellPrompt({ + subscribe: sshSubscribe, + write: (d) => { + if (options.initialPrompt) { + handles.push( + waitForShellPrompt({ + subscribe: sshSubscribe, + write: (d2) => { + stream.write(d2); + this.promptHandles.delete(options.id); + }, + data: options.initialPrompt + '\n', + onTimeout: () => + log.warn( + '[RemotePtyService] Agent prompt not detected, sending initial prompt anyway' + ), + }) + ); + } + stream.write(d); + if (!options.initialPrompt) { + this.promptHandles.delete(options.id); + } + }, + data: fullCommand + '\n', + onTimeout: () => + log.warn( + '[RemotePtyService] Shell prompt not detected, sending setup commands anyway' + ), + }) + ); const pty: RemotePty = { id: options.id, @@ -129,6 +167,7 @@ export class RemotePtyService extends EventEmitter { this.ptys.set(options.id, pty); stream.on('close', () => { + this.cancelPromptHandles(options.id); this.ptys.delete(options.id); this.emit('exit', options.id); }); @@ -165,6 +204,14 @@ export class RemotePtyService extends EventEmitter { } } + private cancelPromptHandles(ptyId: string): void { + const handles = this.promptHandles.get(ptyId); + if (handles) { + for (const h of handles) h.cancel(); + this.promptHandles.delete(ptyId); + } + } + /** * Kills a remote PTY session. * @@ -173,6 +220,7 @@ export class RemotePtyService extends EventEmitter { kill(ptyId: string): void { const pty = this.ptys.get(ptyId); if (pty) { + this.cancelPromptHandles(ptyId); pty.kill(); this.ptys.delete(ptyId); } diff --git a/src/main/services/SentryService.ts b/src/main/services/SentryService.ts new file mode 100644 index 0000000000..0aa8bde0fb --- /dev/null +++ b/src/main/services/SentryService.ts @@ -0,0 +1,275 @@ +import { request } from 'node:https'; +import { URL } from 'node:url'; +import { app } from 'electron'; +import { join } from 'node:path'; +import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; + +import type { SentryIssue } from '../../shared/integrations/sentryTypes'; + +export type { SentryIssue }; + +const SENTRY_API_BASE = 'https://sentry.io/api/0'; +const REQUEST_TIMEOUT_MS = 15_000; + +export interface SentryOrganization { + slug: string; + name: string; +} + +export interface SentryProject { + id: string; + slug: string; + name: string; + organization: { slug: string }; +} + +export interface SentryConnectionStatus { + connected: boolean; + organizationName?: string; + error?: string; +} + +export class SentryService { + private readonly SERVICE_NAME = 'emdash-sentry'; + private readonly ACCOUNT_NAME = 'auth-token'; + + async saveToken( + token: string, + organizationSlug?: string + ): Promise<{ success: boolean; organizationName?: string; error?: string }> { + try { + await this.storeToken(token); + + // Verify the token works by fetching organizations + const orgs = await this.fetchOrganizations(token); + if (orgs.length === 0) { + throw new Error('No organizations found for this token.'); + } + + // Use provided org slug or default to first org + const org = organizationSlug + ? (orgs.find((o) => o.slug === organizationSlug) ?? orgs[0]) + : orgs[0]; + + this.saveOrgSlug(org.slug); + + void import('../telemetry').then(({ capture }) => { + void capture('sentry_connected'); + }); + + return { + success: true, + organizationName: org.name, + }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Failed to validate Sentry token. Please try again.'; + return { success: false, error: message }; + } + } + + async clearToken(): Promise<{ success: boolean; error?: string }> { + try { + const keytar = await import('keytar'); + await keytar.deletePassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + this.clearOrgSlug(); + + void import('../telemetry').then(({ capture }) => { + void capture('sentry_disconnected'); + }); + + return { success: true }; + } catch (error) { + console.error('Failed to clear Sentry token:', error); + return { + success: false, + error: 'Unable to remove Sentry token from keychain.', + }; + } + } + + async checkConnection(): Promise { + try { + const token = await this.getStoredToken(); + if (!token) { + return { connected: false }; + } + + const orgs = await this.fetchOrganizations(token); + const savedSlug = this.loadOrgSlug(); + const org = savedSlug ? (orgs.find((o) => o.slug === savedSlug) ?? orgs[0]) : orgs[0]; + + if (!org) { + return { connected: false, error: 'No organizations found.' }; + } + + return { + connected: true, + organizationName: org.name, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to verify Sentry connection.'; + return { connected: false, error: message }; + } + } + + async initialFetch(limit = 25): Promise { + const token = await this.getStoredToken(); + if (!token) { + throw new Error('Sentry token not set. Connect Sentry in settings first.'); + } + + const orgSlug = this.loadOrgSlug(); + if (!orgSlug) { + throw new Error('No Sentry organization configured.'); + } + + const sanitizedLimit = Math.min(Math.max(limit, 1), 100); + const url = `${SENTRY_API_BASE}/organizations/${orgSlug}/issues/?query=is:unresolved&limit=${sanitizedLimit}&sort=date`; + + return this.apiGet(token, url); + } + + async searchIssues(searchTerm: string, limit = 25): Promise { + const token = await this.getStoredToken(); + if (!token) { + throw new Error('Sentry token not set. Connect Sentry in settings first.'); + } + + const orgSlug = this.loadOrgSlug(); + if (!orgSlug) { + throw new Error('No Sentry organization configured.'); + } + + const trimmed = searchTerm.trim(); + if (!trimmed) return []; + + const sanitizedLimit = Math.min(Math.max(limit, 1), 100); + const encodedQuery = encodeURIComponent(`is:unresolved ${trimmed}`); + const url = `${SENTRY_API_BASE}/organizations/${orgSlug}/issues/?query=${encodedQuery}&limit=${sanitizedLimit}&sort=date`; + + return this.apiGet(token, url); + } + + // ------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------- + + private async fetchOrganizations(token: string): Promise { + return this.apiGet(token, `${SENTRY_API_BASE}/organizations/`); + } + + private async apiGet(token: string, urlStr: string): Promise { + return new Promise((resolve, reject) => { + const url = new URL(urlStr); + + const req = request( + { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error('Invalid or expired Sentry auth token. Please check your token.')); + return; + } + + if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) { + reject( + new Error(`Sentry API error (${res.statusCode}): ${data || res.statusMessage}`) + ); + return; + } + + try { + resolve(JSON.parse(data) as T); + } catch (error) { + reject(error); + } + }); + } + ); + + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(new Error('Sentry API request timed out.')); + }); + + req.on('error', (error) => { + reject(error); + }); + + req.end(); + }); + } + + private saveOrgSlug(orgSlug: string): void { + try { + const filePath = join(app.getPath('userData'), 'sentry.json'); + writeFileSync(filePath, JSON.stringify({ orgSlug }), 'utf-8'); + } catch (error) { + console.error('Failed to save Sentry org slug:', error); + } + } + + private loadOrgSlug(): string | null { + try { + const filePath = join(app.getPath('userData'), 'sentry.json'); + const data = JSON.parse(readFileSync(filePath, 'utf-8')); + return data?.orgSlug ?? null; + } catch { + return null; + } + } + + private clearOrgSlug(): void { + try { + unlinkSync(join(app.getPath('userData'), 'sentry.json')); + } catch { + // file may not exist + } + } + + private async storeToken(token: string): Promise { + const clean = token.trim(); + if (!clean) { + throw new Error('Sentry auth token cannot be empty.'); + } + + try { + const keytar = await import('keytar'); + await keytar.setPassword(this.SERVICE_NAME, this.ACCOUNT_NAME, clean); + } catch (error) { + console.error('Failed to store Sentry token:', error); + throw new Error('Unable to store Sentry token securely.'); + } + } + + private async getStoredToken(): Promise { + try { + const keytar = await import('keytar'); + return await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME); + } catch (error) { + console.error('Failed to read Sentry token from keychain:', error); + return null; + } + } +} + +export const sentryService = new SentryService(); + +export default SentryService; diff --git a/src/main/services/SkillsService.ts b/src/main/services/SkillsService.ts index d42397096e..7f250d4cf0 100644 --- a/src/main/services/SkillsService.ts +++ b/src/main/services/SkillsService.ts @@ -13,6 +13,41 @@ const CATALOG_INDEX_PATH = path.join(EMDASH_META, 'catalog-index.json'); const MAX_REDIRECTS = 5; +const SKILLS_SH_SEARCH_URL = 'https://skills.sh/api/search'; + +/** Convert a kebab-case name to Title Case (e.g. "code-review" → "Code Review"). */ +function titleCase(kebab: string): string { + return kebab + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +/** Deduplicate skills by id — first occurrence wins. */ +function deduplicateById(skills: CatalogSkill[]): CatalogSkill[] { + const seen = new Set(); + return skills.filter((s) => { + if (seen.has(s.id)) return false; + seen.add(s.id); + return true; + }); +} + +/** skills.sh API response shape */ +interface SkillsShSearchResult { + query: string; + searchType: string; + skills: Array<{ + id: string; + skillId: string; + name: string; + installs: number; + source: string; + }>; + count: number; + duration_ms: number; +} + function httpsGet(url: string, redirectCount = 0): Promise { return new Promise((resolve, reject) => { if (redirectCount >= MAX_REDIRECTS) { @@ -49,11 +84,11 @@ function httpsGet(url: string, redirectCount = 0): Promise { } export class SkillsService { - private static readonly CATALOG_VERSION = 2; + private static readonly CATALOG_VERSION = 3; private catalogCache: CatalogIndex | null = null; async initialize(): Promise { - await fs.promises.mkdir(SKILLS_ROOT, { recursive: true }); + // EMDASH_META is inside SKILLS_ROOT, so creating it recursively covers both await fs.promises.mkdir(EMDASH_META, { recursive: true }); } @@ -94,15 +129,25 @@ export class SkillsService { if (anthropicSkills.status === 'fulfilled') { allSkills.push(...anthropicSkills.value); } - - // Deduplicate by id — first occurrence wins - const seen = new Set(); - const skills = allSkills.filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; + allSkills.push({ + id: 'mmx-cli', + displayName: 'MiniMax CLI', + description: 'Generate text, images, video, speech, and music via MiniMax AI platform', + source: 'skills-sh', + iconUrl: 'https://github.com/MiniMax-AI.png?size=80', + brandColor: '#171717', + defaultPrompt: 'Use MiniMax to generate content (text, image, video, speech, or music).', + frontmatter: { + name: 'mmx-cli', + description: 'Generate text, images, video, speech, and music via MiniMax AI platform', + }, + installed: false, + owner: 'MiniMax-AI', + repo: 'cli', }); + const skills = deduplicateById(allSkills); + // If both failed, fall back to bundled if (skills.length === 0) { log.warn('Failed to fetch any remote catalogs, using bundled'); @@ -182,9 +227,28 @@ export class SkillsService { return skills; } - async getSkillDetail(skillId: string): Promise { + async getSkillDetail( + skillId: string, + source?: { owner: string; repo: string } + ): Promise { const catalog = await this.getCatalogIndex(); - const skill = catalog.skills.find((s) => s.id === skillId); + let skill = catalog.skills.find((s) => s.id === skillId) ?? null; + + // If not in catalog but source info provided (search result), construct a shell + if (!skill && source) { + skill = { + id: skillId, + displayName: titleCase(skillId), + description: '', + source: 'skills-sh', + iconUrl: source.owner ? `https://github.com/${source.owner}.png?size=80` : undefined, + frontmatter: { name: skillId, description: '' }, + installed: false, + owner: source.owner, + repo: source.repo, + }; + } + if (!skill) return null; // If installed, load the full SKILL.md from disk @@ -197,14 +261,17 @@ export class SkillsService { } } - // For uninstalled catalog skills, fetch SKILL.md from GitHub + // For uninstalled catalog/search skills, fetch SKILL.md from GitHub if (!skill.installed && !skill.skillMdContent) { try { - const mdUrl = this.getSkillMdUrl(skill); - if (mdUrl) { - const content = await httpsGet(mdUrl); - return { ...skill, skillMdContent: content }; - } + const content = await this.fetchSkillMd(skill); + const { frontmatter: fm } = parseFrontmatter(content); + return { + ...skill, + skillMdContent: content, + description: skill.description || fm.description || '', + displayName: skill.displayName || fm.name || titleCase(skill.id), + }; } catch { // Return what we have } @@ -213,28 +280,131 @@ export class SkillsService { return skill; } - private getSkillMdUrl(skill: CatalogSkill): string | null { - if (skill.source === 'openai' && skill.sourceUrl) { - // e.g. https://github.com/openai/skills/tree/main/skills/.curated/linear - // → https://raw.githubusercontent.com/openai/skills/main/skills/.curated/linear/SKILL.md + /** + * Resolve a deterministic raw-GitHub URL for OpenAI/Anthropic skills whose + * `sourceUrl` already encodes the exact path. Returns null for skills-sh + * skills (those need the multi-path fallback in `fetchSkillMd`). + */ + private getKnownSkillMdUrl(skill: CatalogSkill): string | null { + if (skill.sourceUrl) { const match = skill.sourceUrl.match(/github\.com\/([^/]+\/[^/]+)\/tree\/main\/(.+)/); if (match) { return `https://raw.githubusercontent.com/${match[1]}/main/${match[2]}/SKILL.md`; } } - if (skill.source === 'anthropic' && skill.sourceUrl) { - const match = skill.sourceUrl.match(/github\.com\/([^/]+\/[^/]+)\/tree\/main\/(.+)/); - if (match) { - return `https://raw.githubusercontent.com/${match[1]}/main/${match[2]}/SKILL.md`; + return null; + } + + /** + * Fetch SKILL.md content for any skill. + * + * For OpenAI/Anthropic the path is deterministic (encoded in sourceUrl). + * For skills-sh the file location varies wildly across repos (skills/, + * .claude/skills/, root, etc.) and the directory name often differs from the + * frontmatter skill name. We use the GitHub Trees API to locate all + * SKILL.md files in the repo and then match by directory name or by + * frontmatter `name` field. + */ + private async fetchSkillMd(skill: CatalogSkill): Promise { + // Fast path — known URL from sourceUrl (openai/anthropic catalogs) + const knownUrl = this.getKnownSkillMdUrl(skill); + if (knownUrl) return httpsGet(knownUrl); + + if (!skill.owner || !skill.repo) { + throw new Error(`No source info for skill "${skill.id}"`); + } + + const raw = (p: string) => + `https://raw.githubusercontent.com/${skill.owner}/${skill.repo}/main/${p}`; + + // Use the GitHub Trees API to find all SKILL.md files in one call + const treeUrl = `https://api.github.com/repos/${skill.owner}/${skill.repo}/git/trees/main?recursive=1`; + let skillMdPaths: string[]; + try { + const treeData = await httpsGet(treeUrl); + const tree = JSON.parse(treeData) as { + tree: Array<{ path: string; type: string }>; + }; + skillMdPaths = tree.tree + .filter((f) => f.type === 'blob' && f.path.endsWith('SKILL.md')) + .map((f) => f.path); + } catch { + // Trees API failed — fall back to guessing common paths + const guesses = [ + `skills/${skill.id}/SKILL.md`, + 'SKILL.md', + `${skill.id}/SKILL.md`, + `.claude/skills/${skill.id}/SKILL.md`, + ]; + for (const p of guesses) { + try { + return await httpsGet(raw(p)); + } catch { + // try next + } } + throw new Error(`SKILL.md not found for skill "${skill.id}"`); } - return null; + + if (skillMdPaths.length === 0) { + throw new Error(`No SKILL.md in repo ${skill.owner}/${skill.repo}`); + } + + // Single SKILL.md in the repo — use it directly + if (skillMdPaths.length === 1) { + return httpsGet(raw(skillMdPaths[0])); + } + + // Multiple SKILL.md files — find the right one. + // 1. Check if any parent directory matches the skillId exactly + const byDir = skillMdPaths.find((p) => { + const parts = p.split('/'); + return parts.length >= 2 && parts[parts.length - 2] === skill.id; + }); + if (byDir) return httpsGet(raw(byDir)); + + // 2. Fetch each SKILL.md and match by frontmatter name + for (const p of skillMdPaths) { + try { + const content = await httpsGet(raw(p)); + const { frontmatter: fm } = parseFrontmatter(content); + if (fm.name === skill.id) return content; + } catch { + // try next + } + } + + // 3. Nothing matched — return the first one as best-effort + return httpsGet(raw(skillMdPaths[0])); } - async installSkill(skillId: string): Promise { + async installSkill( + skillId: string, + source?: { owner: string; repo: string } + ): Promise { + if (!isValidSkillName(skillId)) { + throw new Error(`Invalid skill ID "${skillId}"`); + } + await this.initialize(); const catalog = await this.getCatalogIndex(); - const skill = catalog.skills.find((s) => s.id === skillId); + let skill = catalog.skills.find((s) => s.id === skillId) ?? null; + + // If not in catalog but source info provided (from skills.sh search), construct a shell + if (!skill && source) { + skill = { + id: skillId, + displayName: titleCase(skillId), + description: '', + source: 'skills-sh', + iconUrl: source.owner ? `https://github.com/${source.owner}.png?size=80` : undefined, + frontmatter: { name: skillId, description: '' }, + installed: false, + owner: source.owner, + repo: source.repo, + }; + } + if (!skill) throw new Error(`Skill "${skillId}" not found in catalog`); if (skill.installed) throw new Error(`Skill "${skillId}" is already installed`); @@ -246,12 +416,7 @@ export class SkillsService { // Try to download the real SKILL.md from GitHub; fall back to generated stub let content: string; try { - const mdUrl = this.getSkillMdUrl(skill); - if (mdUrl) { - content = await httpsGet(mdUrl); - } else { - content = generateSkillMd(skill.displayName, skill.description); - } + content = await this.fetchSkillMd(skill); } catch { content = generateSkillMd(skill.displayName, skill.description); } @@ -284,6 +449,10 @@ export class SkillsService { } async uninstallSkill(skillId: string): Promise { + if (!isValidSkillName(skillId)) { + throw new Error(`Invalid skill ID "${skillId}"`); + } + const skillDir = path.join(SKILLS_ROOT, skillId); // Remove agent symlinks first @@ -430,13 +599,7 @@ export class SkillsService { const installed = await this.getInstalledSkills(); const installedMap = new Map(installed.map((s) => [s.id, s])); - // Deduplicate catalog skills by id (first occurrence wins) - const seen = new Set(); - const dedupedSkills = catalog.skills.filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; - }); + const dedupedSkills = deduplicateById(catalog.skills); const mergedSkills = dedupedSkills.map((skill) => { const local = installedMap.get(skill.id); @@ -489,10 +652,7 @@ export class SkillsService { // Fetch openai.yaml for each skill in parallel (with fallback) const skills = await Promise.all( allEntries.map(async (entry) => { - const fallbackName = entry.name - .split('-') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); + const fallbackName = titleCase(entry.name); let displayName = fallbackName; let description = ''; @@ -563,10 +723,7 @@ export class SkillsService { for (const entry of entries) { if (entry.type !== 'dir') continue; - const fallbackName = entry.name - .split('-') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); + const fallbackName = titleCase(entry.name); let description = ''; @@ -599,6 +756,52 @@ export class SkillsService { return skills; } + /** + * Search the skills.sh ecosystem via their public API. + * Returns up to 100 matching skills sorted by relevance/installs. + */ + async searchSkillsSh(query: string): Promise { + if (query.length < 2) return []; + + const url = `${SKILLS_SH_SEARCH_URL}?q=${encodeURIComponent(query)}`; + const data = await httpsGet(url); + const result = JSON.parse(data) as SkillsShSearchResult; + + if (!result.skills || !Array.isArray(result.skills)) return []; + + // Check installed skills so we can mark them + const installed = await this.getInstalledSkills(); + const installedIds = new Set(installed.map((s) => s.id)); + + return result.skills + .filter((s) => s.skillId && s.source) + .map((s) => { + // source is "owner/repo" — split safely + const slashIdx = s.source.indexOf('/'); + const owner = slashIdx > 0 ? s.source.slice(0, slashIdx) : s.source; + const repo = slashIdx > 0 ? s.source.slice(slashIdx + 1) : ''; + + const isInstalled = installedIds.has(s.skillId); + const localSkill = isInstalled ? installed.find((i) => i.id === s.skillId) : undefined; + + return { + id: s.skillId, + displayName: titleCase(s.skillId), + description: '', + source: 'skills-sh' as const, + brandColor: '#171717', + // Use the GitHub org/user avatar as the skill icon + iconUrl: owner ? `https://github.com/${owner}.png?size=80` : undefined, + frontmatter: { name: s.skillId, description: '' }, + installed: isInstalled, + localPath: localSkill?.localPath, + owner, + repo, + installs: s.installs, + }; + }); + } + /** Minimal YAML parser for openai.yaml interface block */ private parseSimpleYaml(content: string): Record { const result: Record = {}; diff --git a/src/main/services/TaskLifecycleService.ts b/src/main/services/TaskLifecycleService.ts index 7f0479184b..8bea14cf4f 100644 --- a/src/main/services/TaskLifecycleService.ts +++ b/src/main/services/TaskLifecycleService.ts @@ -1,5 +1,4 @@ import { EventEmitter } from 'node:events'; -import { spawn, type ChildProcess } from 'node:child_process'; import path from 'node:path'; import { promisify } from 'node:util'; import { lifecycleScriptsService } from './LifecycleScriptsService'; @@ -15,6 +14,7 @@ import { import { getTaskEnvVars } from '@shared/task/envVars'; import { log } from '../lib/logger'; import { execFile } from 'node:child_process'; +import { startLifecyclePty, type LifecyclePtyHandle } from './ptyManager'; const execFileAsync = promisify(execFile); @@ -27,8 +27,8 @@ type LifecycleResult = { class TaskLifecycleService extends EventEmitter { private states = new Map(); private logBuffers = new Map(); - private runProcesses = new Map(); - private finiteProcesses = new Map>(); + private runPtys = new Map(); + private finitePtys = new Map>(); private runStartInflight = new Map>(); private setupInflight = new Map>(); private teardownInflight = new Map>(); @@ -42,42 +42,63 @@ class TaskLifecycleService extends EventEmitter { return `${taskId}::${taskPath}`; } - private killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void { - const pid = proc.pid; - if (!pid) return; - - if (process.platform === 'win32') { - const args = ['/PID', String(pid), '/T']; - if (signal === 'SIGKILL') { - args.push('/F'); - } - const killer = spawn('taskkill', args, { stdio: 'ignore' }); - killer.unref(); - return; - } - - try { - // Detached shell commands run as their own process group. - process.kill(-pid, signal); - } catch { - proc.kill(signal); - } - } - - private trackFiniteProcess(taskId: string, proc: ChildProcess): () => void { - const set = this.finiteProcesses.get(taskId) ?? new Set(); - set.add(proc); - this.finiteProcesses.set(taskId, set); + private trackFinitePty(taskId: string, pty: LifecyclePtyHandle): () => void { + const set = this.finitePtys.get(taskId) ?? new Set(); + set.add(pty); + this.finitePtys.set(taskId, set); return () => { - const current = this.finiteProcesses.get(taskId); + const current = this.finitePtys.get(taskId); if (!current) return; - current.delete(proc); + current.delete(pty); if (current.size === 0) { - this.finiteProcesses.delete(taskId); + this.finitePtys.delete(taskId); } }; } + private createLifecyclePty( + id: string, + script: string, + cwd: string, + env: NodeJS.ProcessEnv + ): LifecyclePtyHandle { + return startLifecyclePty({ + id, + command: script, + cwd, + env, + }); + } + + private waitForPtyExit( + handle: LifecyclePtyHandle, + isTracked: () => boolean, + timeoutMs: number, + timeoutMessage: string + ): Promise { + if (!isTracked()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + log.warn(timeoutMessage); + finish(); + }, timeoutMs); + handle.onExit(() => finish()); + if (!isTracked()) { + finish(); + } + }); + } + private async resolveDefaultBranch(projectPath: string): Promise { try { const { stdout } = await execFileAsync( @@ -161,6 +182,18 @@ class TaskLifecycleService extends EventEmitter { } } + private buildErrorDetail(taskId: string, phase: LifecyclePhase, baseError: string): string { + const buf = this.logBuffers.get(taskId); + const lines = buf?.[phase] ?? []; + // Grab last few non-empty output lines for context + const tail = lines + .map((l) => l.replace(/^\[.*?\]\s*/, '').trim()) + .filter(Boolean) + .slice(-5); + if (tail.length === 0) return baseError; + return `${baseError}\n${tail.join('\n')}`; + } + private emitLifecycleEvent( taskId: string, phase: LifecyclePhase, @@ -215,25 +248,25 @@ class TaskLifecycleService extends EventEmitter { }; try { const env = await this.buildLifecycleEnv(taskId, taskPath, projectPath, taskName); - const child = spawn(script, { - cwd: taskPath, - shell: true, - env, - detached: true, - }); - const untrackFinite = this.trackFiniteProcess(taskId, child); - const onData = (buf: Buffer) => { - const line = buf.toString(); + const pty = this.createLifecyclePty( + `lifecycle-${phase}-${taskId}`, + script, + taskPath, + env + ); + const untrackFinite = this.trackFinitePty(taskId, pty); + pty.onData((line) => { + if (!this.finitePtys.get(taskId)?.has(pty)) return; this.emitLifecycleEvent(taskId, phase, 'line', { line }); - }; - child.stdout?.on('data', onData); - child.stderr?.on('data', onData); - child.on('error', (error) => { + }); + pty.onError((error) => { + if (!this.finitePtys.get(taskId)?.has(pty)) return; untrackFinite(); const message = error?.message || String(error); this.emitLifecycleEvent(taskId, phase, 'error', { error: message }); + const detail = this.buildErrorDetail(taskId, phase, message); finish( - { ok: false, error: message }, + { ok: false, error: detail }, { ...state[phase], status: 'failed', @@ -242,19 +275,22 @@ class TaskLifecycleService extends EventEmitter { } ); }); - child.on('exit', (code) => { + pty.onExit((code) => { + if (!this.finitePtys.get(taskId)?.has(pty)) return; untrackFinite(); const ok = code === 0; this.emitLifecycleEvent(taskId, phase, ok ? 'done' : 'error', { exitCode: code, ...(ok ? {} : { error: `Exited with code ${String(code)}` }), }); - finish(ok ? { ok: true } : { ok: false, error: `Exited with code ${String(code)}` }, { + const errorMsg = `Exited with code ${String(code)}`; + const detail = ok ? undefined : this.buildErrorDetail(taskId, phase, errorMsg); + finish(ok ? { ok: true } : { ok: false, error: detail }, { ...state[phase], status: ok ? 'succeeded' : 'failed', finishedAt: this.nowIso(), exitCode: code, - error: ok ? null : `Exited with code ${String(code)}`, + error: ok ? null : errorMsg, }); }); } catch (error) { @@ -318,25 +354,28 @@ class TaskLifecycleService extends EventEmitter { const setupScript = lifecycleScriptsService.getScript(projectPath, 'setup'); if (setupScript) { const setupStatus = this.ensureState(taskId).setup.status; - if (setupStatus === 'running') { + if (setupStatus === 'idle' || setupStatus === 'failed') { + log.info(`Auto-running setup before run (state was ${setupStatus})`, { taskId }); + const setupResult = await this.runSetup(taskId, taskPath, projectPath, taskName); + if (!setupResult.ok) { + return { ok: false, error: `Setup failed: ${setupResult.error}` }; + } + } else if (setupStatus === 'running') { return { ok: false, error: 'Setup is still running' }; } - if (setupStatus === 'failed') { - return { ok: false, error: 'Setup failed. Fix setup before starting run' }; - } - if (setupStatus !== 'succeeded') { - return { ok: false, error: 'Setup has not completed yet' }; - } } const script = lifecycleScriptsService.getScript(projectPath, 'run'); if (!script) return { ok: true, skipped: true }; - const existing = this.runProcesses.get(taskId); - if (existing && existing.exitCode === null && !existing.killed) { + const existing = this.runPtys.get(taskId); + if (existing && !this.stopIntents.has(taskId)) { return { ok: true, skipped: true }; } + // Clear any residual stop intent so the new process's exit is not misclassified. + this.stopIntents.delete(taskId); + const state = this.ensureState(taskId); state.run = { status: 'running', @@ -350,24 +389,17 @@ class TaskLifecycleService extends EventEmitter { try { const env = await this.buildLifecycleEnv(taskId, taskPath, projectPath, taskName); - const child = spawn(script, { - cwd: taskPath, - shell: true, - env, - detached: true, - }); - this.runProcesses.set(taskId, child); - state.run.pid = child.pid ?? null; + const pty = this.createLifecyclePty(`lifecycle-run-${taskId}`, script, taskPath, env); + this.runPtys.set(taskId, pty); + state.run.pid = pty.pid; - const onData = (buf: Buffer) => { - const line = buf.toString(); + pty.onData((line) => { + if (this.runPtys.get(taskId) !== pty) return; this.emitLifecycleEvent(taskId, 'run', 'line', { line }); - }; - child.stdout?.on('data', onData); - child.stderr?.on('data', onData); - child.on('error', (error) => { - if (this.runProcesses.get(taskId) !== child) return; - this.runProcesses.delete(taskId); + }); + pty.onError((error) => { + if (this.runPtys.get(taskId) !== pty) return; + this.runPtys.delete(taskId); this.stopIntents.delete(taskId); const message = error?.message || String(error); const cur = this.ensureState(taskId); @@ -379,9 +411,9 @@ class TaskLifecycleService extends EventEmitter { }; this.emitLifecycleEvent(taskId, 'run', 'error', { error: message }); }); - child.on('exit', (code) => { - if (this.runProcesses.get(taskId) !== child) return; - this.runProcesses.delete(taskId); + pty.onExit((code) => { + if (this.runPtys.get(taskId) !== pty) return; + this.runPtys.delete(taskId); const wasStopped = this.stopIntents.has(taskId); this.stopIntents.delete(taskId); const cur = this.ensureState(taskId); @@ -411,17 +443,75 @@ class TaskLifecycleService extends EventEmitter { } } - stopRun(taskId: string): LifecycleResult { - const proc = this.runProcesses.get(taskId); - if (!proc) return { ok: true, skipped: true }; + async stopRun( + taskId: string, + taskPath?: string, + projectPath?: string, + taskName?: string + ): Promise { + const pty = this.runPtys.get(taskId); + if (!pty) return { ok: true, skipped: true }; this.stopIntents.add(taskId); + + // Run a configured stop script before killing the process. + if (projectPath && taskPath) { + const stopScript = lifecycleScriptsService.getScript(projectPath, 'stop'); + if (stopScript) { + try { + const env = await this.buildLifecycleEnv(taskId, taskPath, projectPath, taskName); + const stopPty = this.createLifecyclePty( + `lifecycle-stop-${taskId}`, + stopScript, + taskPath, + env + ); + const untrack = this.trackFinitePty(taskId, stopPty); + stopPty.onData((line) => { + if (!this.finitePtys.get(taskId)?.has(stopPty)) return; + this.emitLifecycleEvent(taskId, 'run', 'line', { line }); + }); + await new Promise((resolve) => { + const timer = setTimeout(() => { + log.warn('Stop script timed out, proceeding to kill', { taskId }); + try { + stopPty.kill(); + } catch {} + resolve(); + }, 30_000); + stopPty.onExit(() => { + clearTimeout(timer); + resolve(); + }); + stopPty.onError(() => { + clearTimeout(timer); + resolve(); + }); + }); + untrack(); + } catch (error) { + log.warn('Failed to run stop script, proceeding to kill', { + taskId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + // If the run process already exited (e.g. the stop script shut it down), we're done. + const currentPty = this.runPtys.get(taskId); + if (!currentPty || currentPty !== pty) { + return { ok: true }; + } + try { - this.killProcessTree(proc, 'SIGTERM'); + pty.kill(); setTimeout(() => { - const current = this.runProcesses.get(taskId); - if (!current || current !== proc) return; - this.killProcessTree(proc, 'SIGKILL'); + const current = this.runPtys.get(taskId); + if (!current || current !== pty) return; + try { + current.kill('SIGKILL'); + } catch {} }, 8_000); return { ok: true }; } catch (error) { @@ -457,25 +547,16 @@ class TaskLifecycleService extends EventEmitter { } // Ensure a managed run process is stopped before teardown starts. - const existingRun = this.runProcesses.get(taskId); + const existingRun = this.runPtys.get(taskId); if (existingRun) { - this.stopRun(taskId); - await new Promise((resolve) => { - let done = false; - const finish = () => { - if (done) return; - done = true; - resolve(); - }; - const timer = setTimeout(() => { - log.warn('Timed out waiting for run process to exit before teardown', { taskId }); - finish(); - }, 10_000); - existingRun.once('exit', () => { - clearTimeout(timer); - finish(); - }); - }); + const waitForExit = this.waitForPtyExit( + existingRun, + () => this.runPtys.get(taskId) === existingRun, + 10_000, + 'Timed out waiting for run process to exit before teardown' + ); + await this.stopRun(taskId, taskPath, projectPath, taskName); + await waitForExit; } return this.runFinite(taskId, taskPath, projectPath, 'teardown', taskName); })().finally(() => { @@ -485,6 +566,27 @@ class TaskLifecycleService extends EventEmitter { return run; } + /** + * Waits for any in-flight setup for the given taskId to complete. + * Silently ignores setup failures — the caller proceeds regardless. + * Used to ensure setup scripts finish before the agent PTY is spawned. + */ + awaitSetup(taskId: string): Promise { + const prefix = `${taskId}::`; + const promises: Promise[] = []; + for (const [key, promise] of this.setupInflight.entries()) { + if (key.startsWith(prefix)) { + promises.push( + promise.then( + () => {}, + () => {} + ) + ); + } + } + return Promise.all(promises).then(() => {}); + } + getState(taskId: string): TaskLifecycleState { return this.ensureState(taskId); } @@ -514,41 +616,45 @@ class TaskLifecycleService extends EventEmitter { } } - const proc = this.runProcesses.get(taskId); - if (proc) { + const pty = this.runPtys.get(taskId); + if (pty) { + this.runPtys.delete(taskId); try { - this.killProcessTree(proc, 'SIGTERM'); + pty.kill(); } catch {} - this.runProcesses.delete(taskId); } - const finite = this.finiteProcesses.get(taskId); + const finite = this.finitePtys.get(taskId); if (finite) { - for (const child of finite) { + this.finitePtys.delete(taskId); + for (const handle of finite) { try { - this.killProcessTree(child, 'SIGTERM'); + handle.kill(); } catch {} } - this.finiteProcesses.delete(taskId); } } shutdown(): void { - for (const [taskId, proc] of this.runProcesses.entries()) { + const runPtys = [...this.runPtys.entries()]; + const finitePtys = [...this.finitePtys.values()]; + + this.runPtys.clear(); + this.finitePtys.clear(); + + for (const [taskId, pty] of runPtys) { try { this.stopIntents.add(taskId); - this.killProcessTree(proc, 'SIGTERM'); + pty.kill(); } catch {} } - for (const procs of this.finiteProcesses.values()) { - for (const proc of procs) { + for (const handles of finitePtys) { + for (const handle of handles) { try { - this.killProcessTree(proc, 'SIGTERM'); + handle.kill(); } catch {} } } - this.runProcesses.clear(); - this.finiteProcesses.clear(); this.runStartInflight.clear(); this.setupInflight.clear(); this.teardownInflight.clear(); diff --git a/src/main/services/WorkspaceProviderService.ts b/src/main/services/WorkspaceProviderService.ts new file mode 100644 index 0000000000..b279a86f70 --- /dev/null +++ b/src/main/services/WorkspaceProviderService.ts @@ -0,0 +1,509 @@ +import { EventEmitter } from 'node:events'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { log } from '../lib/logger'; +import { capture } from '../telemetry'; +import { getDrizzleClient } from '../db/drizzleClient'; +import { workspaceInstances, sshConnections, type WorkspaceInstanceRow } from '../db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { sshService } from './ssh/SshService'; + +/** Show a warning if provisioning exceeds 5 minutes. */ +const PROVISION_WARNING_TIMEOUT_MS = 5 * 60 * 1000; + +/** Default timeout for terminate scripts (2 minutes). */ +const TERMINATE_TIMEOUT_MS = 2 * 60 * 1000; + +/** + * JSON shape returned by the provision script on stdout. + * Only `host` is required. + */ +export interface ProvisionOutput { + id?: string; + host: string; + port?: number; + username?: string; + worktreePath?: string; +} + +export interface ProvisionConfig { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; +} + +export interface TerminateConfig { + instanceId: string; + terminateCommand: string; + projectPath: string; + /** Extra env vars forwarded to the terminate script. */ + env?: Record; +} + +/** + * Manages remote workspace provisioning and termination via user-defined + * shell scripts. Emits events so the renderer can stream progress: + * + * - `provision-progress` { instanceId, line } + * - `provision-timeout-warning` { instanceId, timeoutMs } + * - `provision-complete` { instanceId, status, error? } + */ +export class WorkspaceProviderService extends EventEmitter { + /** In-flight provision processes keyed by instanceId. */ + private provisionProcesses = new Map(); + /** Provision attempts the user has explicitly cancelled. */ + private cancelledProvisionIds = new Set(); + + // --------------------------------------------------------------------------- + // Provision + // --------------------------------------------------------------------------- + + /** + * Starts provisioning a remote workspace. + * + * 1. Creates a `workspace_instances` row with status `provisioning`. + * 2. Spawns the provision script as a child process. + * 3. Streams stderr lines via `provision-progress` events. + * 4. On success: parses JSON stdout, creates an `ssh_connections` row, + * verifies SSH connectivity, updates the instance to `ready`. + * 5. On failure: updates the instance to `error`. + * + * Returns the instanceId immediately (non-blocking). + */ + async provision(config: ProvisionConfig): Promise { + const instanceId = randomUUID(); + + // Create the DB row before spawning so we can track the attempt. + const { db } = await getDrizzleClient(); + await db.insert(workspaceInstances).values({ + id: instanceId, + taskId: config.taskId, + host: '', // placeholder until script returns + status: 'provisioning', + createdAt: Date.now(), + }); + + capture('workspace_provisioning_started'); + + // Fire and forget — the caller listens for events. + this.runProvision(instanceId, config).catch((err) => { + log.error('[WorkspaceProvider] Unhandled provision error', { instanceId, error: err }); + }); + + return instanceId; + } + + /** Cancel an in-flight provision by killing the child process. */ + async cancel(instanceId: string): Promise { + this.cancelledProvisionIds.add(instanceId); + const child = this.provisionProcesses.get(instanceId); + if (child) { + child.kill('SIGTERM'); + this.provisionProcesses.delete(instanceId); + } + await this.updateStatus(instanceId, 'error'); + } + + // --------------------------------------------------------------------------- + // Terminate + // --------------------------------------------------------------------------- + + /** + * Runs the terminate script for a workspace instance. + * + * On success: updates the instance to `terminated` and deletes the + * associated `ssh_connections` row. + * On failure: updates the instance to `error` (rows kept for retry). + */ + async terminate(config: TerminateConfig): Promise { + const instance = await this.getInstance(config.instanceId); + if (!instance) { + throw new Error(`Workspace instance ${config.instanceId} not found`); + } + + const envVars: Record = { + EMDASH_INSTANCE_ID: instance.externalId || instance.host, + EMDASH_TASK_ID: instance.taskId, + ...(config.env ?? {}), + }; + + try { + const stderrLines: string[] = []; + const result = await this.runScript({ + command: config.terminateCommand, + cwd: config.projectPath, + envVars, + hardTimeoutMs: TERMINATE_TIMEOUT_MS, + onStderr: (line) => stderrLines.push(line), + }); + + if (result.exitCode !== 0) { + const logs = stderrLines.join('\n').trim(); + throw new Error( + `Terminate script exited with code ${result.exitCode}` + (logs ? `:\n${logs}` : '') + ); + } + + // Clean up the SSH connection row if one exists. + if (instance.connectionId) { + const { db } = await getDrizzleClient(); + await db.delete(sshConnections).where(eq(sshConnections.id, instance.connectionId)); + } + + await this.updateStatus(config.instanceId, 'terminated', Date.now()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[WorkspaceProvider] Terminate failed', { + instanceId: config.instanceId, + error: message, + }); + await this.updateStatus(config.instanceId, 'error'); + capture('workspace_provisioning_failed', { error_type: 'terminate' }); + throw err; + } + } + + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + /** Get a workspace instance by ID. */ + async getInstance(instanceId: string): Promise { + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(workspaceInstances) + .where(eq(workspaceInstances.id, instanceId)) + .limit(1); + return rows[0] ?? null; + } + + /** Get the active workspace instance for a task (provisioning or ready). */ + async getActiveInstance(taskId: string): Promise { + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(workspaceInstances) + .where( + and( + eq(workspaceInstances.taskId, taskId), + inArray(workspaceInstances.status, ['provisioning', 'ready']) + ) + ) + .limit(1); + return rows[0] ?? null; + } + + /** Get all workspace instances with a given status. */ + async getInstancesByStatus( + status: 'provisioning' | 'ready' | 'terminated' | 'error' + ): Promise { + const { db } = await getDrizzleClient(); + return db.select().from(workspaceInstances).where(eq(workspaceInstances.status, status)); + } + + // --------------------------------------------------------------------------- + // Reconnection (called on app startup) + // --------------------------------------------------------------------------- + + /** + * On app restart, mark any `provisioning` instances as `error` (the child + * process is dead) and attempt to reconnect `ready` instances. + */ + async reconcileOnStartup(): Promise { + // Mark stale provisioning attempts as errors. + const stale = await this.getInstancesByStatus('provisioning'); + for (const instance of stale) { + log.warn('[WorkspaceProvider] Marking stale provisioning instance as error', { + instanceId: instance.id, + taskId: instance.taskId, + }); + await this.updateStatus(instance.id, 'error'); + } + + // Verify ready instances are still reachable. + const ready = await this.getInstancesByStatus('ready'); + for (const instance of ready) { + if (!instance.connectionId) { + await this.updateStatus(instance.id, 'error'); + continue; + } + const connected = sshService.isConnected(instance.connectionId); + if (!connected) { + log.info('[WorkspaceProvider] Ready instance not connected, will need reconnection', { + instanceId: instance.id, + taskId: instance.taskId, + }); + // Don't mark as error — the UI will show "reconnect" option. + // The SSH connection will be re-established when the user opens the task. + } + } + } + + // --------------------------------------------------------------------------- + // Internal: provision flow + // --------------------------------------------------------------------------- + + private async runProvision(instanceId: string, config: ProvisionConfig): Promise { + const envVars: Record = { + EMDASH_TASK_ID: config.taskId, + EMDASH_REPO_URL: config.repoUrl, + EMDASH_BRANCH: config.branch, + EMDASH_BASE_REF: config.baseRef, + }; + + let stdout = ''; + let stderr = ''; + + try { + const result = await this.runScript({ + command: config.provisionCommand, + cwd: config.projectPath, + envVars, + warningTimeoutMs: PROVISION_WARNING_TIMEOUT_MS, + onTimeoutWarning: () => { + this.emit('provision-timeout-warning', { + instanceId, + timeoutMs: PROVISION_WARNING_TIMEOUT_MS, + }); + }, + onStderr: (line) => { + stderr += line; + this.emit('provision-progress', { instanceId, line }); + }, + onStdout: (data) => { + stdout += data; + }, + trackProcess: (child) => { + this.provisionProcesses.set(instanceId, child); + if (this.cancelledProvisionIds.has(instanceId)) { + child.kill('SIGTERM'); + } + }, + }); + + // Clean up process tracking. + this.provisionProcesses.delete(instanceId); + + if (result.exitCode !== 0) { + if (this.cancelledProvisionIds.has(instanceId)) { + throw new Error('Workspace provisioning was cancelled.'); + } + throw new Error( + `Provision script exited with code ${result.exitCode}.\n${stderr.slice(-500)}` + ); + } + + this.cancelledProvisionIds.delete(instanceId); + + // Parse the JSON output from stdout. + const output = this.parseProvisionOutput(stdout); + + // Create an SSH connection row for this workspace. + const connectionId = await this.createSshConnection(instanceId, output); + + // Update the workspace instance with the real data. + const { db } = await getDrizzleClient(); + await db + .update(workspaceInstances) + .set({ + externalId: output.id ?? null, + host: output.host, + port: output.port ?? 22, + username: output.username ?? null, + worktreePath: output.worktreePath ?? null, + connectionId, + }) + .where(eq(workspaceInstances.id, instanceId)); + + // Skip ssh2-based verification — the terminal uses system `ssh` which + // reads ~/.ssh/config and the macOS keychain agent. ssh2 cannot do + // either, so verification would false-negative for SSH config aliases + // and macOS agent-stored keys. If SSH is actually unreachable the + // user will see it fail in the terminal and can retry. + await this.updateStatus(instanceId, 'ready'); + capture('workspace_provisioning_success'); + this.emit('provision-complete', { instanceId, status: 'ready' }); + } catch (err) { + this.provisionProcesses.delete(instanceId); + const wasCancelled = this.cancelledProvisionIds.delete(instanceId); + const message = wasCancelled + ? 'Workspace provisioning was cancelled.' + : err instanceof Error + ? err.message + : String(err); + + if (wasCancelled) { + log.info('[WorkspaceProvider] Provision cancelled', { instanceId }); + } else { + log.error('[WorkspaceProvider] Provision failed', { instanceId, error: message }); + } + + await this.updateStatus(instanceId, 'error'); + if (!wasCancelled) { + capture('workspace_provisioning_failed', { error_type: 'provision' }); + } + this.emit('provision-complete', { instanceId, status: 'error', error: message }); + } + } + + // --------------------------------------------------------------------------- + // Internal: script runner + // --------------------------------------------------------------------------- + + private runScript(opts: { + command: string; + cwd: string; + envVars: Record; + hardTimeoutMs?: number; + warningTimeoutMs?: number; + onTimeoutWarning?: () => void; + onStderr?: (line: string) => void; + onStdout?: (data: string) => void; + trackProcess?: (child: ChildProcess) => void; + }): Promise<{ exitCode: number }> { + return new Promise((resolve, reject) => { + const env = { ...process.env, ...opts.envVars }; + + const child = spawn('bash', ['-c', opts.command], { + cwd: opts.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + opts.trackProcess?.(child); + + let settled = false; + const finish = (result: { exitCode: number } | Error) => { + if (settled) return; + settled = true; + clearTimeout(hardTimeoutTimer); + clearTimeout(warningTimer); + if (result instanceof Error) { + reject(result); + } else { + resolve(result); + } + }; + + const hardTimeoutMs = opts.hardTimeoutMs; + const hardTimeoutTimer = + hardTimeoutMs == null + ? undefined + : setTimeout(() => { + child.kill('SIGTERM'); + finish(new Error(`Script timed out after ${hardTimeoutMs / 1000}s`)); + }, hardTimeoutMs); + + const warningTimer = + opts.warningTimeoutMs == null + ? undefined + : setTimeout(() => { + opts.onTimeoutWarning?.(); + }, opts.warningTimeoutMs); + + child.stdout?.on('data', (buf: Buffer) => { + opts.onStdout?.(buf.toString('utf-8')); + }); + + child.stderr?.on('data', (buf: Buffer) => { + const text = buf.toString('utf-8'); + // Emit per-line for the UI. + for (const line of text.split('\n')) { + if (line.trim()) { + opts.onStderr?.(line); + } + } + }); + + child.on('error', (err) => { + finish(new Error(`Failed to spawn script: ${err.message}`)); + }); + + child.on('exit', (code) => { + finish({ exitCode: code ?? -1 }); + }); + }); + } + + // --------------------------------------------------------------------------- + // Internal: helpers + // --------------------------------------------------------------------------- + + private parseProvisionOutput(stdout: string): ProvisionOutput { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error('Provision script produced no output on stdout.'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error( + 'Provision script output is not valid JSON. ' + + 'Ensure all log output goes to stderr (>&2) and only JSON is printed to stdout.' + ); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Provision script output must be a JSON object.'); + } + + const obj = parsed as Record; + + if (typeof obj.host !== 'string' || !obj.host.trim()) { + throw new Error( + 'Provision script output must include a "host" field (string). ' + + 'This can be a hostname, IP, or SSH config alias.' + ); + } + + return { + id: typeof obj.id === 'string' ? obj.id : undefined, + host: obj.host.trim(), + port: typeof obj.port === 'number' ? obj.port : undefined, + username: typeof obj.username === 'string' ? obj.username : undefined, + worktreePath: typeof obj.worktreePath === 'string' ? obj.worktreePath : undefined, + }; + } + + private async createSshConnection(instanceId: string, output: ProvisionOutput): Promise { + const connectionId = `workspace-${instanceId}`; + const { db } = await getDrizzleClient(); + const now = new Date().toISOString(); + + await db.insert(sshConnections).values({ + id: connectionId, + name: `workspace-${instanceId.slice(0, 8)}-${output.host}`, + host: output.host, + port: output.port ?? 22, + username: output.username ?? process.env.USER ?? 'root', + authType: 'agent', + useAgent: 1, + createdAt: now, + updatedAt: now, + }); + + return connectionId; + } + + private async updateStatus( + instanceId: string, + status: string, + terminatedAt?: number + ): Promise { + const { db } = await getDrizzleClient(); + const set: Record = { status }; + if (terminatedAt !== undefined) { + set.terminatedAt = terminatedAt; + } + await db.update(workspaceInstances).set(set).where(eq(workspaceInstances.id, instanceId)); + } +} + +/** Module-level singleton. */ +export const workspaceProviderService = new WorkspaceProviderService(); diff --git a/src/main/services/WorktreePoolService.ts b/src/main/services/WorktreePoolService.ts index 267bb1a31e..a1bf27d106 100644 --- a/src/main/services/WorktreePoolService.ts +++ b/src/main/services/WorktreePoolService.ts @@ -15,6 +15,8 @@ interface ReserveWorktree { projectId: string; projectPath: string; baseRef: string; + resolvedRef: string; + commitHash: string; createdAt: string; } @@ -36,11 +38,17 @@ export class WorktreePoolService { // Keyed by `${projectId}::${baseRef}` to keep reserves base-ref specific. private reserves = new Map(); private creationInProgress = new Set(); + private creationPromises = new Map>(); + private preflightPromises = new Map>(); private readonly RESERVE_PREFIX = '_reserve'; // Reserves older than this are considered stale and will be recreated // 30 minutes is reasonable since users don't create tasks that frequently private readonly MAX_RESERVE_AGE_MS = 30 * 60 * 1000; // 30 minutes + private pollTimer: ReturnType | undefined; + private isPolling = false; + private readonly FRESHNESS_POLL_INTERVAL_MS = 60_000; + /** Generate a unique hash for reserve identification */ private generateReserveHash(): string { const bytes = crypto.randomBytes(4); @@ -57,11 +65,38 @@ export class WorktreePoolService { return `${this.RESERVE_PREFIX}/${hash}`; } + /** Strip "origin/" prefix from a remote tracking ref */ + private stripRemotePrefix(ref: string): string { + return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref; + } + private normalizeBaseRef(baseRef?: string): string { const trimmed = (baseRef || '').trim(); return trimmed.length > 0 ? trimmed : 'HEAD'; } + /** + * Resolve baseRef to a canonical branch name for consistent reserve keys. + * - `HEAD` → resolved to actual branch name (e.g. `main`) + * - `origin/main` → stripped to `main` + * - `main` → kept as-is + * Falls back to the normalized baseRef if resolution fails. + */ + private async resolveCanonicalBaseRef(projectPath: string, baseRef?: string): Promise { + const normalized = this.normalizeBaseRef(baseRef); + try { + if (normalized === 'HEAD') { + const { stdout } = await execFileAsync('git', ['symbolic-ref', '--short', 'HEAD'], { + cwd: projectPath, + }); + return stdout.trim() || normalized; + } + return this.stripRemotePrefix(normalized); + } catch { + return this.stripRemotePrefix(normalized); + } + } + private getReserveKey(projectId: string, baseRef?: string): string { return `${projectId}::${this.normalizeBaseRef(baseRef)}`; } @@ -159,11 +194,13 @@ export class WorktreePoolService { * Creates one in the background if not present. */ async ensureReserve(projectId: string, projectPath: string, baseRef?: string): Promise { - const reserveKey = this.getReserveKey(projectId, baseRef); + const canonical = await this.resolveCanonicalBaseRef(projectPath, baseRef); + const reserveKey = this.getReserveKey(projectId, canonical); - // Creation already in progress - if (this.creationInProgress.has(reserveKey)) { - return; + // Creation already in progress — return the existing promise so callers can await it + const existing$ = this.creationPromises.get(reserveKey); + if (existing$) { + return existing$; } // Check existing reserve @@ -177,16 +214,20 @@ export class WorktreePoolService { this.cleanupReserve(existing).catch(() => {}); } - // Start background creation + // Start creation and store the promise so others can join this.creationInProgress.add(reserveKey); - try { - await this.createReserve(projectId, projectPath, this.normalizeBaseRef(baseRef)); - } catch (error) { - log.warn('WorktreePool: Failed to create reserve', { projectId, baseRef, error }); - } finally { - this.creationInProgress.delete(reserveKey); - } + const creation$ = this.createReserve(projectId, projectPath, canonical) + .catch((error) => { + log.warn('WorktreePool: Failed to create reserve', { projectId, baseRef, error }); + }) + .finally(() => { + this.creationInProgress.delete(reserveKey); + this.creationPromises.delete(reserveKey); + }); + + this.creationPromises.set(reserveKey, creation$); + return creation$; } /** @@ -207,6 +248,14 @@ export class WorktreePoolService { fs.mkdirSync(worktreesDir, { recursive: true }); } + // Skip reserve creation for empty repos (no commits). + // The first createWorktree() call will initialize the repo, + // and the pool will replenish on the next task creation. + if (!(await worktreeService.hasCommits(projectPath))) { + log.info('WorktreePool: Skipping reserve creation for empty repository', { projectId }); + return; + } + // Keep reserve refs fresh in the background so claim remains instant. await this.refreshRefsForReserveCreation(projectPath, projectId); @@ -224,6 +273,12 @@ export class WorktreePoolService { } ); + // Capture the commit hash the reserve was created from + const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', 'HEAD'], { + cwd: reservePath, + }); + const commitHash = hashOut.trim(); + const reserveId = this.stableIdFromPath(reservePath); const reserve: ReserveWorktree = { id: reserveId, @@ -232,10 +287,13 @@ export class WorktreePoolService { projectId, projectPath, baseRef, + resolvedRef, + commitHash, createdAt: new Date().toISOString(), }; this.reserves.set(this.getReserveKey(projectId, baseRef), reserve); + this.startFreshnessPoll(); } /** @@ -248,7 +306,7 @@ export class WorktreePoolService { taskName: string, requestedBaseRef?: string ): Promise { - const resolvedBaseRef = this.normalizeBaseRef(requestedBaseRef); + const resolvedBaseRef = await this.resolveCanonicalBaseRef(projectPath, requestedBaseRef); const reserveKey = this.getReserveKey(projectId, resolvedBaseRef); const reserve = this.reserves.get(reserveKey); if (!reserve) { @@ -284,6 +342,58 @@ export class WorktreePoolService { } } + /** + * Preflight freshness check for a specific project's reserve. + * Called when the create-task UI opens so the ls-remote cost is paid while + * the user fills in the form. If the reserve is stale it is recreated. + * Returns a promise that resolves when the check (and potential recreation) + * is complete — the renderer should await this before claiming. + */ + async preflightCheck(projectId: string, projectPath: string): Promise { + // Deduplicate: if a preflight is already running for this project, join it + const existing$ = this.preflightPromises.get(projectId); + if (existing$) { + return existing$; + } + + const preflight$ = this.runPreflightCheck(projectId, projectPath).finally(() => { + this.preflightPromises.delete(projectId); + }); + this.preflightPromises.set(projectId, preflight$); + return preflight$; + } + + private async runPreflightCheck(projectId: string, projectPath: string): Promise { + const prefix = `${projectId}::`; + + // Wait for any in-progress reserve creations for this project (in parallel) + const creationWaits: Promise[] = []; + for (const [key, promise] of this.creationPromises) { + if (key.startsWith(prefix)) { + log.info('WorktreePool: preflight — waiting for in-progress reserve creation', { + projectId, + key, + }); + creationWaits.push(promise); + } + } + if (creationWaits.length > 0) { + await Promise.all(creationWaits); + } + + // Collect all reserves for this project + const entries = Array.from(this.reserves.entries()).filter(([key]) => key.startsWith(prefix)); + + if (entries.length === 0) { + log.info('WorktreePool: preflight — no reserves found for project', { projectId }); + // Create a reserve so the claim has something to work with + await this.ensureReserve(projectId, projectPath, 'HEAD'); + return; + } + + await Promise.all(entries.map(([key, reserve]) => this.refreshReserveIfStale(key, reserve))); + } + /** * Transform a reserve worktree into a task worktree */ @@ -551,13 +661,97 @@ export class WorktreePoolService { } } + /** Start polling reserves for freshness (idempotent) */ + private startFreshnessPoll(): void { + if (this.isPolling) return; + this.isPolling = true; + this.schedulePollTick(); + } + + /** Schedule the next poll tick after POLL_INTERVAL_MS */ + private schedulePollTick(): void { + this.pollTimer = setTimeout(async () => { + this.pollTimer = undefined; + await this.checkAndRefreshReserves().catch(() => {}); + if (this.isPolling) { + this.schedulePollTick(); + } + }, this.FRESHNESS_POLL_INTERVAL_MS); + } + + /** Stop freshness polling */ + private stopFreshnessPoll(): void { + this.isPolling = false; + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = undefined; + } + } + + /** + * Check a single reserve against its current ref (remote or local) and + * recreate it if the ref has advanced past the reserve's commit. + */ + private async refreshReserveIfStale(key: string, reserve: ReserveWorktree): Promise { + try { + let currentHash: string | undefined; + + if (reserve.resolvedRef.startsWith('origin/')) { + // Remote-tracking: use ls-remote (no full fetch needed) + const branchName = this.stripRemotePrefix(reserve.resolvedRef); + const { stdout: lsOut } = await execFileAsync('git', ['ls-remote', 'origin', branchName], { + cwd: reserve.projectPath, + timeout: 10000, + }); + currentHash = lsOut.split(/\s/)[0]?.trim(); + } else { + // Local-only: resolve the branch ref directly (instant, no network) + const { stdout } = await execFileAsync('git', ['rev-parse', reserve.resolvedRef], { + cwd: reserve.projectPath, + }); + currentHash = stdout.trim(); + } + + const stale = !!currentHash && currentHash !== reserve.commitHash; + + log.info('WorktreePool: freshness check', { + key, + resolvedRef: reserve.resolvedRef, + reserveHash: reserve.commitHash, + currentHash: currentHash || '(empty)', + stale, + }); + + if (!stale) return; + + this.reserves.delete(key); + await this.cleanupReserve(reserve); + await this.ensureReserve(reserve.projectId, reserve.projectPath, reserve.baseRef); + log.info('WorktreePool: reserve recreated', { key }); + } catch { + // Failures are non-critical — skip this reserve + } + } + + /** Check all reserves against their remote refs and recreate stale ones */ + private async checkAndRefreshReserves(): Promise { + const reserves = Array.from(this.reserves.entries()); + if (reserves.length === 0) { + this.stopFreshnessPoll(); + return; + } + + await Promise.all(reserves.map(([key, reserve]) => this.refreshReserveIfStale(key, reserve))); + } + /** Cleanup all reserves (e.g., on app shutdown) */ async cleanup(): Promise { - for (const [projectId, reserve] of this.reserves) { + this.stopFreshnessPoll(); + for (const [key, reserve] of this.reserves) { try { await this.cleanupReserve(reserve); } catch (error) { - log.warn('WorktreePool: Failed to cleanup reserve on shutdown', { projectId, error }); + log.warn('WorktreePool: Failed to cleanup reserve on shutdown', { key, error }); } } this.reserves.clear(); diff --git a/src/main/services/WorktreeService.ts b/src/main/services/WorktreeService.ts index 2ae09511d8..56264ba4f8 100644 --- a/src/main/services/WorktreeService.ts +++ b/src/main/services/WorktreeService.ts @@ -225,6 +225,15 @@ export class WorktreeService { } else { baseRefInfo = await this.resolveProjectBaseRef(projectPath, projectId); } + + // Initialize empty repos on the same branch we intend to use as the worktree base. + if (!(await this.hasCommits(projectPath))) { + const initBranch = await this.initializeEmptyRepo(projectPath, baseRefInfo.branch); + log.info( + `Empty repo initialized with branch '${initBranch}', proceeding with worktree creation` + ); + } + const fetchedBaseRef = await this.fetchBaseRefWithFallback( projectPath, projectId, @@ -646,7 +655,10 @@ export class WorktreeService { cwd: projectPath, }); const match = stdout.match(/HEAD branch:\s*(\S+)/); - return match ? match[1] : 'main'; + if (match && match[1] !== '(unknown)') { + return match[1]; + } + return 'main'; } catch { return 'main'; } @@ -847,6 +859,17 @@ export class WorktreeService { throw new Error(`Failed to fetch ${target.fullRef}: ${message}`); } + // Remote ref is missing — check if the branch exists locally (e.g. after initializing an empty repo). + try { + await execFileAsync('git', ['rev-parse', '--verify', `refs/heads/${target.branch}`], { + cwd: projectPath, + }); + log.info(`Remote ref ${target.fullRef} is missing, using local branch '${target.branch}'`); + return { remote: '', branch: target.branch, fullRef: target.branch }; + } catch { + // Local branch doesn't exist either; continue to fallback logic. + } + // Attempt fallback to default branch const fallback = await this.buildDefaultBaseRef(projectPath); if (fallback.fullRef === target.fullRef) { @@ -905,6 +928,33 @@ export class WorktreeService { } } + async hasCommits(projectPath: string): Promise { + try { + await execFileAsync('git', ['rev-parse', '--verify', 'HEAD'], { + cwd: projectPath, + }); + return true; + } catch { + return false; + } + } + + private async initializeEmptyRepo(projectPath: string, branchName: string): Promise { + const targetBranch = this.sanitizeBranchName(branchName.trim() || 'main'); + + log.info(`Initializing empty repository with initial commit on branch '${targetBranch}'`); + + await execFileAsync('git', ['symbolic-ref', 'HEAD', `refs/heads/${targetBranch}`], { + cwd: projectPath, + }); + + await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial commit'], { + cwd: projectPath, + }); + + return targetBranch; + } + /** * Merge worktree changes back to main branch */ diff --git a/src/main/services/__tests__/AutomationsService.test.ts b/src/main/services/__tests__/AutomationsService.test.ts new file mode 100644 index 0000000000..2b0bc12d76 --- /dev/null +++ b/src/main/services/__tests__/AutomationsService.test.ts @@ -0,0 +1,1070 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mocks — must be defined before importing the module under test +// --------------------------------------------------------------------------- + +const mockGetPath = vi.fn().mockReturnValue('/tmp/test-automations'); + +vi.mock('electron', () => ({ + app: { getPath: (...args: unknown[]) => mockGetPath(...args) }, +})); + +vi.mock('../../lib/logger', () => ({ + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Use real filesystem via a temp directory +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { getDrizzleClient, resetDrizzleClient } from '../../db/drizzleClient'; + +// We need to dynamically import the module AFTER mocks are set up. +// The service file exports a singleton, so we import fresh for each test suite. +let tmpDir: string; + +/** + * Create the automations tables in the test database by executing the + * migration SQL file directly. This mirrors what DatabaseService.ensureMigrations() + * does in production, keeping the migration file as the single source of truth. + */ +async function createAutomationsTables(): Promise { + const drizzleDir = path.join(__dirname, '..', '..', '..', '..', 'drizzle'); + const { sqlite } = await getDrizzleClient(); + + for (const file of ['0011_add_automations_tables.sql', '0012_add_automation_triggers.sql']) { + const migrationSql = await fs.readFile(path.join(drizzleDir, file), 'utf-8'); + await new Promise((resolve, reject) => { + sqlite.exec(migrationSql, (err) => (err ? reject(err) : resolve())); + }); + } +} + +beforeEach(async () => { + await resetDrizzleClient(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automations-test-')); + mockGetPath.mockReturnValue(tmpDir); + // Reset singleton state so each test starts with a fresh initialization cycle + automationsService?._resetForTesting(); + await createAutomationsTables(); +}); + +afterEach(async () => { + await resetDrizzleClient(); + vi.clearAllMocks(); + // Clean up temp files + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Import the service — since it's a singleton we need to work with the same +// instance but it reads from disk each time, so changing tmpDir is enough. +// --------------------------------------------------------------------------- +let automationsService: Awaited['automationsService']; + +beforeAll(async () => { + const mod = await import('../AutomationsService'); + automationsService = mod.automationsService; +}); + +/** Helper to access the private inFlightRuns set without repeating the cast. */ +function getInFlightRuns(): Set { + return (automationsService as unknown as { inFlightRuns: Set }).inFlightRuns; +} + +// --------------------------------------------------------------------------- +// computeNextRun — tested indirectly via create + schedule +// We can also test it by creating automations and checking nextRunAt +// --------------------------------------------------------------------------- + +describe('AutomationsService', () => { + describe('CRUD operations', () => { + it('should create an automation and assign an ID', async () => { + const automation = await automationsService.create({ + name: 'Test Automation', + projectId: 'proj-1', + projectName: 'My Project', + prompt: 'Run tests', + agentId: 'claude-code', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + expect(automation.id).toMatch(/^auto_/); + expect(automation.name).toBe('Test Automation'); + expect(automation.projectId).toBe('proj-1'); + expect(automation.projectName).toBe('My Project'); + expect(automation.prompt).toBe('Run tests'); + expect(automation.agentId).toBe('claude-code'); + expect(automation.status).toBe('active'); + expect(automation.runCount).toBe(0); + expect(automation.lastRunAt).toBeNull(); + expect(automation.lastRunResult).toBeNull(); + expect(automation.nextRunAt).toBeTruthy(); + expect(automation.useWorktree).toBe(true); + }); + + it('should default useWorktree to true', async () => { + const automation = await automationsService.create({ + name: 'No Worktree', + projectId: 'proj-1', + prompt: 'test', + agentId: 'claude-code', + schedule: { type: 'daily', hour: 12, minute: 0 }, + }); + + expect(automation.useWorktree).toBe(true); + }); + + it('should allow useWorktree = false', async () => { + const automation = await automationsService.create({ + name: 'No Worktree', + projectId: 'proj-1', + prompt: 'test', + agentId: 'claude-code', + schedule: { type: 'daily', hour: 12, minute: 0 }, + useWorktree: false, + }); + + expect(automation.useWorktree).toBe(false); + }); + + it('should list all automations', async () => { + await automationsService.create({ + name: 'First', + projectId: 'p1', + prompt: 'first', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 8, minute: 0 }, + }); + await automationsService.create({ + name: 'Second', + projectId: 'p2', + prompt: 'second', + agentId: 'agent-2', + schedule: { type: 'weekly', hour: 10, minute: 30, dayOfWeek: 'mon' }, + }); + + const list = await automationsService.list(); + expect(list).toHaveLength(2); + expect(list[0].name).toBe('First'); + expect(list[1].name).toBe('Second'); + }); + + it('should get an automation by ID', async () => { + const created = await automationsService.create({ + name: 'Get Test', + projectId: 'p1', + prompt: 'get me', + agentId: 'agent-1', + schedule: { type: 'hourly', minute: 15 }, + }); + + const fetched = await automationsService.get(created.id); + expect(fetched).not.toBeNull(); + expect(fetched!.id).toBe(created.id); + expect(fetched!.name).toBe('Get Test'); + }); + + it('should return null for non-existent ID', async () => { + const result = await automationsService.get('nonexistent'); + expect(result).toBeNull(); + }); + + it('should update an automation', async () => { + const created = await automationsService.create({ + name: 'Original', + projectId: 'p1', + prompt: 'original prompt', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + const updated = await automationsService.update({ + id: created.id, + name: 'Updated Name', + prompt: 'updated prompt', + }); + + expect(updated).not.toBeNull(); + expect(updated!.name).toBe('Updated Name'); + expect(updated!.prompt).toBe('updated prompt'); + expect(updated!.agentId).toBe('agent-1'); // unchanged + }); + + it('should recalculate nextRunAt when schedule is updated', async () => { + const created = await automationsService.create({ + name: 'Schedule Update', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + const originalNext = created.nextRunAt; + + const updated = await automationsService.update({ + id: created.id, + schedule: { type: 'weekly', hour: 14, minute: 30, dayOfWeek: 'fri' }, + }); + + expect(updated!.nextRunAt).not.toBe(originalNext); + expect(updated!.schedule.type).toBe('weekly'); + }); + + it('should return null when updating non-existent ID', async () => { + const result = await automationsService.update({ + id: 'nonexistent', + name: 'nope', + }); + expect(result).toBeNull(); + }); + + it('should delete an automation', async () => { + const created = await automationsService.create({ + name: 'To Delete', + projectId: 'p1', + prompt: 'delete me', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + const deleted = await automationsService.delete(created.id); + expect(deleted).toBe(true); + + const fetched = await automationsService.get(created.id); + expect(fetched).toBeNull(); + }); + + it('should return false when deleting non-existent ID', async () => { + const result = await automationsService.delete('nonexistent'); + expect(result).toBe(false); + }); + + it('should toggle status active → paused → active', async () => { + const created = await automationsService.create({ + name: 'Toggle Test', + projectId: 'p1', + prompt: 'toggle me', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + expect(created.status).toBe('active'); + + const paused = await automationsService.toggleStatus(created.id); + expect(paused!.status).toBe('paused'); + + const reactivated = await automationsService.toggleStatus(created.id); + expect(reactivated!.status).toBe('active'); + expect(reactivated!.nextRunAt).toBeTruthy(); + }); + + it('should clear error state when re-activating', async () => { + const created = await automationsService.create({ + name: 'Error Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + // Set an error state + await automationsService.setLastRunResult(created.id, 'failure', 'Something broke'); + + // Pause then reactivate + await automationsService.toggleStatus(created.id); + const reactivated = await automationsService.toggleStatus(created.id); + + expect(reactivated!.lastRunError).toBeNull(); + }); + }); + + describe('validateSchedule', () => { + it('should reject invalid schedule types', async () => { + await expect( + automationsService.create({ + name: 'Bad Schedule', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'biweekly' as never }, + }) + ).rejects.toThrow('Invalid schedule type'); + }); + + it('should reject invalid hour values', async () => { + await expect( + automationsService.create({ + name: 'Bad Hour', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 25, minute: 0 }, + }) + ).rejects.toThrow('Invalid hour'); + }); + + it('should reject negative hour values', async () => { + await expect( + automationsService.create({ + name: 'Negative Hour', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: -1, minute: 0 }, + }) + ).rejects.toThrow('Invalid hour'); + }); + + it('should reject invalid minute values', async () => { + await expect( + automationsService.create({ + name: 'Bad Minute', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 12, minute: 60 }, + }) + ).rejects.toThrow('Invalid minute'); + }); + + it('should reject invalid dayOfWeek', async () => { + await expect( + automationsService.create({ + name: 'Bad Day', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'weekly', hour: 9, minute: 0, dayOfWeek: 'xyz' as never }, + }) + ).rejects.toThrow('Invalid dayOfWeek'); + }); + + it('should reject invalid dayOfMonth', async () => { + await expect( + automationsService.create({ + name: 'Bad DOM', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'monthly', hour: 9, minute: 0, dayOfMonth: 32 }, + }) + ).rejects.toThrow('Invalid dayOfMonth'); + }); + + it('should reject dayOfMonth of 0', async () => { + await expect( + automationsService.create({ + name: 'Zero DOM', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'monthly', hour: 9, minute: 0, dayOfMonth: 0 }, + }) + ).rejects.toThrow('Invalid dayOfMonth'); + }); + + it('should accept valid schedules at boundary values', async () => { + // Hour 0, Minute 0 + const a = await automationsService.create({ + name: 'Midnight', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 0, minute: 0 }, + }); + expect(a.schedule.hour).toBe(0); + + // Hour 23, Minute 59 + const b = await automationsService.create({ + name: 'Late Night', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 23, minute: 59 }, + }); + expect(b.schedule.hour).toBe(23); + expect(b.schedule.minute).toBe(59); + + // Day of month 31 + const c = await automationsService.create({ + name: 'End of Month', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'monthly', hour: 9, minute: 0, dayOfMonth: 31 }, + }); + expect(c.schedule.dayOfMonth).toBe(31); + }); + }); + + describe('computeNextRun (via create)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should compute next hourly run', async () => { + // Set time to 14:20:00 + vi.setSystemTime(new Date(2025, 5, 15, 14, 20, 0)); + + const automation = await automationsService.create({ + name: 'Hourly', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'hourly', minute: 30 }, + }); + + const next = new Date(automation.nextRunAt!); + // Next :30 after 14:20 is 14:30 + expect(next.getHours()).toBe(14); + expect(next.getMinutes()).toBe(30); + }); + + it('should advance to next hour when minute has passed', async () => { + // Set time to 14:45:00 + vi.setSystemTime(new Date(2025, 5, 15, 14, 45, 0)); + + const automation = await automationsService.create({ + name: 'Hourly Past', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'hourly', minute: 30 }, + }); + + const next = new Date(automation.nextRunAt!); + // Next :30 after 14:45 is 15:30 + expect(next.getHours()).toBe(15); + expect(next.getMinutes()).toBe(30); + }); + + it('should compute next daily run', async () => { + // Set time to 2025-06-15 10:00:00 + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + + const automation = await automationsService.create({ + name: 'Daily', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + const next = new Date(automation.nextRunAt!); + // Same day, 14:00 + expect(next.getDate()).toBe(15); + expect(next.getHours()).toBe(14); + }); + + it('should advance daily to next day when time has passed', async () => { + // Set time to 2025-06-15 16:00:00 + vi.setSystemTime(new Date(2025, 5, 15, 16, 0, 0)); + + const automation = await automationsService.create({ + name: 'Daily Tomorrow', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + const next = new Date(automation.nextRunAt!); + // Next day, 14:00 + expect(next.getDate()).toBe(16); + expect(next.getHours()).toBe(14); + }); + + it('should compute next weekly run', async () => { + // 2025-06-15 is a Sunday + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + + const automation = await automationsService.create({ + name: 'Weekly', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'weekly', hour: 9, minute: 0, dayOfWeek: 'wed' }, + }); + + const next = new Date(automation.nextRunAt!); + // Next Wednesday after Sunday June 15 = June 18 + expect(next.getDate()).toBe(18); + expect(next.getDay()).toBe(3); // Wednesday + expect(next.getHours()).toBe(9); + }); + + it('should advance weekly to next week when day+time has passed', async () => { + // 2025-06-18 is a Wednesday, set time after 9:00 + vi.setSystemTime(new Date(2025, 5, 18, 12, 0, 0)); + + const automation = await automationsService.create({ + name: 'Weekly Next', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'weekly', hour: 9, minute: 0, dayOfWeek: 'wed' }, + }); + + const next = new Date(automation.nextRunAt!); + // Next Wednesday = June 25 + expect(next.getDate()).toBe(25); + expect(next.getDay()).toBe(3); + }); + + it('should compute next monthly run', async () => { + // June 5, before the 15th + vi.setSystemTime(new Date(2025, 5, 5, 10, 0, 0)); + + const automation = await automationsService.create({ + name: 'Monthly', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 }, + }); + + const next = new Date(automation.nextRunAt!); + // Same month, June 15 + expect(next.getMonth()).toBe(5); + expect(next.getDate()).toBe(15); + }); + + it('should advance monthly to next month when day has passed', async () => { + // June 20, after the 15th + vi.setSystemTime(new Date(2025, 5, 20, 10, 0, 0)); + + const automation = await automationsService.create({ + name: 'Monthly Next', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 }, + }); + + const next = new Date(automation.nextRunAt!); + // Next month, July 15 + expect(next.getMonth()).toBe(6); + expect(next.getDate()).toBe(15); + }); + + it('should clamp dayOfMonth to last day of month (Feb 30 → Feb 28)', async () => { + // Set to Feb 1 — schedule for day 30 but Feb only has 28 days + vi.setSystemTime(new Date(2025, 1, 1, 10, 0, 0)); + + const automation = await automationsService.create({ + name: 'Month Clamp', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'monthly', hour: 9, minute: 0, dayOfMonth: 30 }, + }); + + const next = new Date(automation.nextRunAt!); + // February 2025 has 28 days, so clamped to 28 + expect(next.getMonth()).toBe(1); // February + expect(next.getDate()).toBeLessThanOrEqual(28); + }); + }); + + describe('run logs', () => { + it('should create and retrieve run logs via manual trigger', async () => { + const automation = await automationsService.create({ + name: 'Run Log Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + const runLogId = await automationsService.createManualRunLog(automation.id); + expect(runLogId).toMatch(/^auto_/); + + const logs = await automationsService.getRunLogs(automation.id); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('running'); + expect(logs[0].automationId).toBe(automation.id); + expect(getInFlightRuns().has(automation.id)).toBe(true); + }); + + it('should update run log status', async () => { + const automation = await automationsService.create({ + name: 'Update Log', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + const runLogId = await automationsService.createManualRunLog(automation.id); + await automationsService.updateRunLog(runLogId, { + status: 'success', + finishedAt: new Date().toISOString(), + taskId: 'task-123', + }); + + const logs = await automationsService.getRunLogs(automation.id); + expect(logs[0].status).toBe('success'); + expect(logs[0].taskId).toBe('task-123'); + expect(logs[0].finishedAt).toBeTruthy(); + }); + + it('should clear in-flight state when a run log finishes', async () => { + const automation = await automationsService.create({ + name: 'Clear In Flight', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + const runLogId = await automationsService.createManualRunLog(automation.id); + + expect(getInFlightRuns().has(automation.id)).toBe(true); + + await automationsService.updateRunLog( + runLogId, + { + status: 'success', + finishedAt: new Date().toISOString(), + }, + automation.id + ); + + expect(getInFlightRuns().has(automation.id)).toBe(false); + }); + + it('should increment runCount on manual trigger', async () => { + const automation = await automationsService.create({ + name: 'Count Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + await automationsService.createManualRunLog(automation.id); + await automationsService.createManualRunLog(automation.id); + + const fetched = await automationsService.get(automation.id); + expect(fetched!.runCount).toBe(2); + }); + + it('should set last run result', async () => { + const automation = await automationsService.create({ + name: 'Result Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + await automationsService.setLastRunResult(automation.id, 'failure', 'Timeout'); + + const fetched = await automationsService.get(automation.id); + expect(fetched!.lastRunResult).toBe('failure'); + expect(fetched!.lastRunError).toBe('Timeout'); + }); + + it('should clean up run logs when automation is deleted', async () => { + const automation = await automationsService.create({ + name: 'Cleanup Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + await automationsService.createManualRunLog(automation.id); + await automationsService.createManualRunLog(automation.id); + + await automationsService.delete(automation.id); + + const logs = await automationsService.getRunLogs(automation.id); + expect(logs).toHaveLength(0); + }); + + it('should limit run logs when queried', async () => { + const automation = await automationsService.create({ + name: 'Limit Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 9, minute: 0 }, + }); + + // Create 5 run logs + for (let i = 0; i < 5; i++) { + await automationsService.createManualRunLog(automation.id); + } + + const limited = await automationsService.getRunLogs(automation.id, 3); + expect(limited).toHaveLength(3); + }); + }); + + describe('reconcileMissedRuns', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should trigger a catch-up run and recalculate nextRunAt for missed schedules', async () => { + const triggerCb = vi.fn(); + automationsService.onTrigger(triggerCb); + + // Create an automation at 10:00 + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Missed', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + // Jump forward 3 days — the nextRunAt is now in the past + vi.setSystemTime(new Date(2025, 5, 18, 16, 0, 0)); + + await automationsService.reconcileMissedRuns(); + + // nextRunAt should advance to the next future occurrence + const fetched = await automationsService.get(automation.id); + const nextRun = new Date(fetched!.nextRunAt!); + expect(nextRun.getDate()).toBe(19); + expect(nextRun.getHours()).toBe(14); + + // Should have triggered the catch-up callback exactly once + expect(triggerCb).toHaveBeenCalledTimes(1); + expect(triggerCb).toHaveBeenCalledWith( + expect.objectContaining({ id: automation.id, name: 'Missed' }), + expect.stringMatching(/^auto_/) + ); + + // Should have created a run log for the catch-up + const logs = await automationsService.getRunLogs(automation.id); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('running'); + expect(getInFlightRuns().has(automation.id)).toBe(true); + + // runCount should be incremented + expect(fetched!.runCount).toBe(1); + expect(fetched!.lastRunAt).toBeTruthy(); + }); + + it('should trigger exactly once even when multiple schedule occurrences were missed', async () => { + const triggerCb = vi.fn(); + automationsService.onTrigger(triggerCb); + + // Create an hourly automation + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + await automationsService.create({ + name: 'Hourly Missed', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'hourly', minute: 0 }, + }); + + // Jump forward 48 hours — many occurrences missed + vi.setSystemTime(new Date(2025, 5, 17, 10, 0, 0)); + + await automationsService.reconcileMissedRuns(); + + // Should only trigger once, not 48 times + expect(triggerCb).toHaveBeenCalledTimes(1); + }); + + it('should not trigger catch-up for paused automations', async () => { + const triggerCb = vi.fn(); + automationsService.onTrigger(triggerCb); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Paused', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + // Pause the automation + await automationsService.toggleStatus(automation.id); + + // Jump forward + vi.setSystemTime(new Date(2025, 5, 18, 16, 0, 0)); + + await automationsService.reconcileMissedRuns(); + + // No trigger for paused automations + expect(triggerCb).not.toHaveBeenCalled(); + }); + + it('should mark orphaned "running" run logs as interrupted', async () => { + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Orphan Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + // Simulate a run that started and was never completed (app crashed) + await automationsService.createManualRunLog(automation.id); + + // Jump forward 30 minutes (within max duration, but app "restarted") + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + await automationsService.reconcileMissedRuns(); + + const logs = await automationsService.getRunLogs(automation.id); + // The orphaned run should be marked as failed + const failedLog = logs.find((l) => l.error === 'Interrupted (app was closed or crashed)'); + expect(failedLog).toBeTruthy(); + expect(failedLog!.status).toBe('failure'); + expect(failedLog!.finishedAt).toBeTruthy(); + + // Automation itself should also reflect the failure + const fetched = await automationsService.get(automation.id); + expect(fetched!.lastRunResult).toBe('failure'); + expect(fetched!.lastRunError).toBe('Interrupted (app was closed or crashed)'); + }); + + it('should clear in-flight state when startup reconciliation fails an orphaned run', async () => { + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'In Flight Cleanup', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + await automationsService.createManualRunLog(automation.id); + + // createManualRunLog populates inFlightRuns via the public API + expect(getInFlightRuns().has(automation.id)).toBe(true); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + await automationsService.reconcileMissedRuns(); + + expect(getInFlightRuns().has(automation.id)).toBe(false); + }); + + it('should mark runs exceeding max duration as timed out', async () => { + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Timeout Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + await automationsService.createManualRunLog(automation.id); + + // Jump forward 3 hours (exceeds 2h max duration) + vi.setSystemTime(new Date(2025, 5, 15, 13, 0, 0)); + await automationsService.reconcileMissedRuns(); + + const logs = await automationsService.getRunLogs(automation.id); + const timedOutLog = logs.find((l) => l.error?.includes('timed out')); + expect(timedOutLog).toBeTruthy(); + expect(timedOutLog!.status).toBe('failure'); + expect(timedOutLog!.error).toMatch(/Run timed out after \d+ minutes/); + }); + + it('should not touch completed run logs during reconcile', async () => { + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Completed Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 14, minute: 0 }, + }); + + const runLogId = await automationsService.createManualRunLog(automation.id); + await automationsService.updateRunLog(runLogId, { + status: 'success', + finishedAt: new Date().toISOString(), + }); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + await automationsService.reconcileMissedRuns(); + + const logs = await automationsService.getRunLogs(automation.id); + const successLog = logs.find((l) => l.id === runLogId); + expect(successLog!.status).toBe('success'); // Untouched + }); + + it('should preserve live in-flight runs when catching up after resume', async () => { + const triggerCb = vi.fn(); + automationsService.onTrigger(triggerCb); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Resume Cleanup Guard', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 10, minute: 15 }, + }); + + const originalNextRunAt = automation.nextRunAt; + const runLogId = await automationsService.createManualRunLog(automation.id); + expect(getInFlightRuns().has(automation.id)).toBe(true); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + await automationsService.reconcileMissedRunsAfterResume(); + + const logs = await automationsService.getRunLogs(automation.id); + expect(logs).toHaveLength(1); + expect(logs[0]).toMatchObject({ + id: runLogId, + status: 'running', + finishedAt: null, + error: null, + }); + + const fetched = await automationsService.get(automation.id); + expect(fetched!.lastRunResult).toBeNull(); + expect(fetched!.lastRunError).toBeNull(); + expect(fetched!.nextRunAt).toBe(originalNextRunAt); + expect(triggerCb).not.toHaveBeenCalled(); + }); + + it('should fail the catch-up run and clear in-flight state when trigger delivery throws', async () => { + automationsService.onTrigger(() => { + throw new Error('Renderer unavailable'); + }); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Delivery Failure', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'daily', hour: 10, minute: 15 }, + }); + + vi.setSystemTime(new Date(2025, 5, 15, 10, 30, 0)); + await automationsService.reconcileMissedRunsAfterResume(); + + const logs = await automationsService.getRunLogs(automation.id); + expect(logs).toHaveLength(1); + expect(logs[0].status).toBe('failure'); + expect(logs[0].error).toBe('Renderer unavailable'); + expect(logs[0].finishedAt).toBeTruthy(); + expect(getInFlightRuns().has(automation.id)).toBe(false); + + const fetched = await automationsService.get(automation.id); + expect(fetched!.lastRunResult).toBe('failure'); + expect(fetched!.lastRunError).toBe('Renderer unavailable'); + }); + + it('should catch-up AND clean up orphaned runs for the same automation', async () => { + const triggerCb = vi.fn(); + automationsService.onTrigger(triggerCb); + + // Create an hourly automation at 10:00 — nextRunAt will be 10:30 + vi.setSystemTime(new Date(2025, 5, 15, 10, 0, 0)); + const automation = await automationsService.create({ + name: 'Both Test', + projectId: 'p1', + prompt: 'test', + agentId: 'agent-1', + schedule: { type: 'hourly', minute: 30 }, + }); + + // Simulate a run that was in progress when app closed + await automationsService.createManualRunLog(automation.id); + + // Jump forward 1 hour — orphaned run is within 2h window (→ "Interrupted"), + // and nextRunAt (10:30) is in the past (→ catch-up triggered) + vi.setSystemTime(new Date(2025, 5, 15, 11, 0, 0)); + + await automationsService.reconcileMissedRuns(); + + // Orphaned run should be marked as interrupted + const logs = await automationsService.getRunLogs(automation.id); + const failedLog = logs.find((l) => l.error?.includes('Interrupted')); + expect(failedLog).toBeTruthy(); + expect(failedLog!.status).toBe('failure'); + + // Catch-up run should be triggered + expect(triggerCb).toHaveBeenCalledTimes(1); + + // A new "running" log should exist for the catch-up + const runningLog = logs.find((l) => l.status === 'running'); + expect(runningLog).toBeTruthy(); + }); + }); + + describe('legacy JSON compatibility', () => { + it('ignores legacy JSON files completely', async () => { + const now = new Date().toISOString(); + + await fs.writeFile(path.join(tmpDir, 'automations.json'), '{ broken json', 'utf-8'); + await fs.writeFile( + path.join(tmpDir, 'automation-runs.json'), + JSON.stringify( + { + runs: [ + { + id: 'auto_run_legacy_1', + automationId: 'auto_legacy_1', + startedAt: now, + finishedAt: now, + status: 'success', + error: null, + taskId: 'task-legacy', + }, + ], + }, + null, + 2 + ), + 'utf-8' + ); + + const list = await automationsService.list(); + expect(list).toEqual([]); + }); + }); + + describe('scheduler', () => { + it('should start and stop without errors', async () => { + // Starting the scheduler should not throw + automationsService.start(); + // Starting again should be a no-op (idempotent) + automationsService.start(); + // Allow the immediate startup tick to complete + await new Promise((resolve) => setTimeout(resolve, 25)); + // Stopping should work + automationsService.stop(); + // Stopping again should be safe + automationsService.stop(); + // Ensure no cached sqlite handle is left open for tmpDir cleanup + await resetDrizzleClient(); + }); + + it('should register trigger callbacks', () => { + const cb = vi.fn(); + // onTrigger should accept a callback without throwing + automationsService.onTrigger(cb); + }); + }); +}); diff --git a/src/main/services/__tests__/RemoteGitService.test.ts b/src/main/services/__tests__/RemoteGitService.test.ts index 7da1aa1f08..cf8c0b749f 100644 --- a/src/main/services/__tests__/RemoteGitService.test.ts +++ b/src/main/services/__tests__/RemoteGitService.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { RemoteGitService, WorktreeInfo, GitStatus } from '../RemoteGitService'; +import { RemoteGitService } from '../RemoteGitService'; import { SshService } from '../ssh/SshService'; import { ExecResult } from '../../../shared/ssh/types'; +import type { GitStatus, WorktreeInfo } from '../../../shared/git/types'; // Mock SshService const mockExecuteCommand = vi.fn(); @@ -16,6 +17,14 @@ vi.mock('../ssh/SshService', () => ({ })), })); +const mockGetAppSettings = vi.fn().mockReturnValue({ + repository: { branchPrefix: 'emdash' }, +}); + +vi.mock('../../settings', () => ({ + getAppSettings: (...args: any[]) => mockGetAppSettings(...args), +})); + describe('RemoteGitService', () => { let service: RemoteGitService; let mockSshService: SshService; @@ -143,7 +152,7 @@ describe('RemoteGitService', () => { expect.stringContaining('git worktree add'), '/home/user/project' ); - expect(result.branch).toContain('task-name'); + expect(result.branch).toMatch(/^emdash\/task-name-/); expect(result.isMain).toBe(false); expect(result.path).toContain('.emdash/worktrees'); }); @@ -183,7 +192,7 @@ describe('RemoteGitService', () => { 'task with spaces & symbols!@#' ); - expect(result.branch).toMatch(/^task-with-spaces-/); + expect(result.branch).toMatch(/^emdash\/task-with-spaces-/); expect(result.branch).not.toContain(' '); expect(result.branch).not.toContain('&'); expect(result.branch).not.toContain('!'); @@ -191,6 +200,24 @@ describe('RemoteGitService', () => { expect(result.branch).not.toContain('#'); }); + it('should use custom branch prefix from settings', async () => { + mockGetAppSettings.mockReturnValue({ + repository: { branchPrefix: 'custom-prefix' }, + }); + + mockExecuteCommand.mockResolvedValue({ + stdout: '', + stderr: '', + exitCode: 0, + } as ExecResult); + + const result = await service.createWorktree('conn-1', '/home/user/project', 'my-task'); + + expect(result.branch).toMatch(/^custom-prefix\/my-task-/); + // Directory path should not contain the prefix + expect(result.path).toMatch(/\.emdash\/worktrees\/my-task-\d+$/); + }); + it('should throw error when worktree creation fails', async () => { mockExecuteCommand .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult) // mkdir succeeds @@ -462,6 +489,25 @@ describe('RemoteGitService', () => { expect(result).toEqual([]); }); + it('throws when porcelain v2 and v1 both fail', async () => { + mockExecuteCommand + .mockResolvedValueOnce({ stdout: 'true', stderr: '', exitCode: 0 } as ExecResult) // rev-parse + .mockResolvedValueOnce({ + stdout: '', + stderr: 'unsupported option', + exitCode: 2, + } as ExecResult) // status v2 + .mockResolvedValueOnce({ + stdout: '', + stderr: 'fatal: not a git repository', + exitCode: 128, + } as ExecResult); // status v1 + + await expect(service.getStatusDetailed('conn-1', '/home/user/project')).rejects.toThrow( + 'fatal: not a git repository' + ); + }); + it('should parse status with additions/deletions from numstat', async () => { mockExecuteCommand .mockResolvedValueOnce({ stdout: 'true', stderr: '', exitCode: 0 } as ExecResult) // rev-parse @@ -546,30 +592,54 @@ describe('RemoteGitService', () => { expect(result[0].status).toBe('renamed'); expect(result[0].isStaged).toBe(true); }); + + it('preserves unknown numstat values as null', async () => { + mockExecuteCommand + .mockResolvedValueOnce({ stdout: 'true', stderr: '', exitCode: 0 } as ExecResult) + .mockResolvedValueOnce({ + stdout: ' M binary.png\n', + stderr: '', + exitCode: 0, + } as ExecResult) + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult) + .mockResolvedValueOnce({ + stdout: '-\t-\tbinary.png\n', + stderr: '', + exitCode: 0, + } as ExecResult); + + const result = await service.getStatusDetailed('conn-1', '/home/user/project'); + + expect(result).toHaveLength(1); + expect(result[0].path).toBe('binary.png'); + expect(result[0].additions).toBeNull(); + expect(result[0].deletions).toBeNull(); + }); }); describe('getFileDiff', () => { it('should parse unified diff output', async () => { mockExecuteCommand .mockResolvedValueOnce({ - stdout: - 'diff --git a/file.ts b/file.ts\nindex abc..def 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,3 @@\n hello\n-old line\n+new line\n world\n', + stdout: '__EMDASH_CONTENT__\nhello\nold line\nworld\n', stderr: '', exitCode: 0, - } as ExecResult) // git diff + } as ExecResult) // HEAD:file .mockResolvedValueOnce({ - stdout: 'hello\nold line\nworld\n', + stdout: '__EMDASH_CONTENT__\nhello\nnew line\nworld\n', stderr: '', exitCode: 0, - } as ExecResult) // git show HEAD:file + } as ExecResult) // working file .mockResolvedValueOnce({ - stdout: 'hello\nnew line\nworld\n', + stdout: + 'diff --git a/file.ts b/file.ts\nindex abc..def 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,3 @@\n hello\n-old line\n+new line\n world\n', stderr: '', exitCode: 0, - } as ExecResult); // cat file + } as ExecResult); // git diff const result = await service.getFileDiff('conn-1', '/home/user/project', 'file.ts'); + expect(result.mode).toBe('text'); expect(result.lines).toHaveLength(4); expect(result.lines[0]).toEqual({ left: 'hello', right: 'hello', type: 'context' }); expect(result.lines[1]).toEqual({ left: 'old line', type: 'del' }); @@ -581,16 +651,21 @@ describe('RemoteGitService', () => { it('should handle untracked file (no diff, read content)', async () => { mockExecuteCommand - .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult) - .mockResolvedValueOnce({ stdout: '', stderr: 'not found', exitCode: 128 } as ExecResult) .mockResolvedValueOnce({ - stdout: 'line1\nline2\nline3\n', + stdout: '__EMDASH_MISSING__\n', stderr: '', exitCode: 0, - } as ExecResult); // cat fallback + } as ExecResult) // HEAD:file missing + .mockResolvedValueOnce({ + stdout: '__EMDASH_CONTENT__\nline1\nline2\nline3\n', + stderr: '', + exitCode: 0, + } as ExecResult) // working file + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); // git diff const result = await service.getFileDiff('conn-1', '/home/user/project', 'newfile.txt'); + expect(result.mode).toBe('text'); expect(result.lines).toHaveLength(3); expect(result.lines[0]).toEqual({ right: 'line1', type: 'add' }); expect(result.lines[1]).toEqual({ right: 'line2', type: 'add' }); @@ -599,27 +674,48 @@ describe('RemoteGitService', () => { expect(result.modifiedContent).toBe('line1\nline2\nline3'); }); + it('classifies untracked files with NUL bytes as binary', async () => { + mockExecuteCommand + .mockResolvedValueOnce({ + stdout: '__EMDASH_MISSING__\n', + stderr: '', + exitCode: 0, + } as ExecResult) // git show HEAD:file wrapper + .mockResolvedValueOnce({ + stdout: '__EMDASH_CONTENT__\nabc\u0000def', + stderr: '', + exitCode: 0, + } as ExecResult); // cat file wrapper + + const result = await service.getFileDiff('conn-1', '/home/user/project', 'image.png'); + + expect(result.mode).toBe('binary'); + expect(result.isBinary).toBe(true); + expect(result.lines).toEqual([]); + }); + it('should handle deleted file with realistic diff output', async () => { mockExecuteCommand .mockResolvedValueOnce({ - stdout: - 'diff --git a/deleted.txt b/deleted.txt\ndeleted file mode 100644\nindex abc1234..0000000\n--- a/deleted.txt\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-old content\n-was here\n', + stdout: '__EMDASH_CONTENT__\nold content\nwas here\n', stderr: '', exitCode: 0, - } as ExecResult) // git diff + } as ExecResult) // HEAD:file .mockResolvedValueOnce({ - stdout: 'old content\nwas here\n', + stdout: '__EMDASH_MISSING__\n', stderr: '', exitCode: 0, - } as ExecResult) // git show HEAD:file + } as ExecResult) // working file missing .mockResolvedValueOnce({ - stdout: '', - stderr: 'No such file or directory', - exitCode: 1, - } as ExecResult); // cat fails — file not on disk + stdout: + 'diff --git a/deleted.txt b/deleted.txt\ndeleted file mode 100644\nindex abc1234..0000000\n--- a/deleted.txt\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-old content\n-was here\n', + stderr: '', + exitCode: 0, + } as ExecResult); // git diff const result = await service.getFileDiff('conn-1', '/home/user/project', 'deleted.txt'); + expect(result.mode).toBe('text'); expect(result.lines).toHaveLength(2); expect(result.lines[0]).toEqual({ left: 'old content', type: 'del' }); expect(result.lines[1]).toEqual({ left: 'was here', type: 'del' }); @@ -629,11 +725,20 @@ describe('RemoteGitService', () => { it('should return empty lines when all fallbacks fail', async () => { mockExecuteCommand - .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult) // git diff (parallel) - .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 1 } as ExecResult) // git show HEAD:file (parallel) - .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 1 } as ExecResult); // cat fallback + .mockResolvedValueOnce({ + stdout: '__EMDASH_MISSING__\n', + stderr: '', + exitCode: 0, + } as ExecResult) // HEAD:file missing + .mockResolvedValueOnce({ + stdout: '__EMDASH_MISSING__\n', + stderr: '', + exitCode: 0, + } as ExecResult) // working file missing + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 1 } as ExecResult); // git diff fails const result = await service.getFileDiff('conn-1', '/home/user/project', 'ghost.txt'); + expect(result.mode).toBe('unrenderable'); expect(result.lines).toEqual([]); expect(result.originalContent).toBeUndefined(); expect(result.modifiedContent).toBeUndefined(); @@ -642,24 +747,25 @@ describe('RemoteGitService', () => { it('should handle staged new file (git show HEAD fails, diff and cat succeed)', async () => { mockExecuteCommand .mockResolvedValueOnce({ - stdout: - 'diff --git a/newfile.ts b/newfile.ts\nnew file mode 100644\nindex 0000000..abc1234\n--- /dev/null\n+++ b/newfile.ts\n@@ -0,0 +1,2 @@\n+line one\n+line two\n', + stdout: '__EMDASH_MISSING__\n', stderr: '', exitCode: 0, - } as ExecResult) // git diff + } as ExecResult) // HEAD:file missing .mockResolvedValueOnce({ - stdout: '', - stderr: 'fatal: Path does not exist', - exitCode: 128, - } as ExecResult) // git show HEAD:file (fails — file not in HEAD) + stdout: '__EMDASH_CONTENT__\nline one\nline two\n', + stderr: '', + exitCode: 0, + } as ExecResult) // working file .mockResolvedValueOnce({ - stdout: 'line one\nline two\n', + stdout: + 'diff --git a/newfile.ts b/newfile.ts\nnew file mode 100644\nindex 0000000..abc1234\n--- /dev/null\n+++ b/newfile.ts\n@@ -0,0 +1,2 @@\n+line one\n+line two\n', stderr: '', exitCode: 0, - } as ExecResult); // cat file + } as ExecResult); // git diff const result = await service.getFileDiff('conn-1', '/home/user/project', 'newfile.ts'); + expect(result.mode).toBe('text'); expect(result.lines).toHaveLength(2); expect(result.lines[0]).toEqual({ right: 'line one', type: 'add' }); expect(result.lines[1]).toEqual({ right: 'line two', type: 'add' }); @@ -670,24 +776,25 @@ describe('RemoteGitService', () => { it('should skip "No newline at end of file" markers', async () => { mockExecuteCommand .mockResolvedValueOnce({ - stdout: - 'diff --git a/file.ts b/file.ts\nindex abc1234..def5678 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,2 +1,2 @@\n hello\n-old line\n\\ No newline at end of file\n+new line\n\\ No newline at end of file\n', + stdout: '__EMDASH_CONTENT__\nhello\nold line', stderr: '', exitCode: 0, - } as ExecResult) // git diff + } as ExecResult) // HEAD:file .mockResolvedValueOnce({ - stdout: 'hello\nold line', + stdout: '__EMDASH_CONTENT__\nhello\nnew line', stderr: '', exitCode: 0, - } as ExecResult) // git show HEAD:file (no trailing newline) + } as ExecResult) // working file .mockResolvedValueOnce({ - stdout: 'hello\nnew line', + stdout: + 'diff --git a/file.ts b/file.ts\nindex abc1234..def5678 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,2 +1,2 @@\n hello\n-old line\n\\ No newline at end of file\n+new line\n\\ No newline at end of file\n', stderr: '', exitCode: 0, - } as ExecResult); // cat file (no trailing newline) + } as ExecResult); // git diff const result = await service.getFileDiff('conn-1', '/home/user/project', 'file.ts'); + expect(result.mode).toBe('text'); expect(result.lines).toHaveLength(3); expect(result.lines[0]).toEqual({ left: 'hello', right: 'hello', type: 'context' }); expect(result.lines[1]).toEqual({ left: 'old line', type: 'del' }); @@ -697,27 +804,110 @@ describe('RemoteGitService', () => { }); it('should detect binary files and return empty lines with isBinary flag', async () => { - mockExecuteCommand.mockResolvedValueOnce({ - stdout: - 'diff --git a/image.png b/image.png\nindex abc1234..def5678 100644\nBinary files a/image.png and b/image.png differ\n', - stderr: '', - exitCode: 0, - } as ExecResult); // git diff only — no content fetch for binary + mockExecuteCommand + .mockResolvedValueOnce({ + stdout: '__EMDASH_MISSING__\n', + stderr: '', + exitCode: 0, + } as ExecResult) // HEAD:file + .mockResolvedValueOnce({ + stdout: '__EMDASH_MISSING__\n', + stderr: '', + exitCode: 0, + } as ExecResult) // working file + .mockResolvedValueOnce({ + stdout: + 'diff --git a/image.png b/image.png\nindex abc1234..def5678 100644\nBinary files a/image.png and b/image.png differ\n', + stderr: '', + exitCode: 0, + } as ExecResult); // git diff const result = await service.getFileDiff('conn-1', '/home/user/project', 'image.png'); expect(result.lines).toEqual([]); + expect(result.mode).toBe('binary'); expect(result.isBinary).toBe(true); - // Verify no additional SSH calls were made for content - expect(mockExecuteCommand).toHaveBeenCalledTimes(1); + expect(mockExecuteCommand).toHaveBeenCalledTimes(3); + }); + + it('uses merge-base and HEAD object content when baseRef is provided', async () => { + mockExecuteCommand + .mockResolvedValueOnce({ + stdout: 'abc123\n', + stderr: '', + exitCode: 0, + } as ExecResult) // git merge-base + .mockResolvedValueOnce({ + stdout: '__EMDASH_CONTENT__\nold\n', + stderr: '', + exitCode: 0, + } as ExecResult) // git show :file + .mockResolvedValueOnce({ + stdout: '__EMDASH_CONTENT__\nnew\n', + stderr: '', + exitCode: 0, + } as ExecResult) // git show HEAD:file + .mockResolvedValueOnce({ + stdout: + 'diff --git a/file.ts b/file.ts\nindex abc..def 100644\n--- a/file.ts\n+++ b/file.ts\n@@ -1,1 +1,1 @@\n-old\n+new\n', + stderr: '', + exitCode: 0, + } as ExecResult); // git diff HEAD + + const result = await service.getFileDiff( + 'conn-1', + '/home/user/project', + 'file.ts', + 'origin/main' + ); + + expect(result.mode).toBe('text'); + expect(result.originalContent).toBe('old'); + expect(result.modifiedContent).toBe('new'); + expect(mockExecuteCommand).toHaveBeenCalledWith( + 'conn-1', + "git merge-base 'origin/main' HEAD", + '/home/user/project' + ); + expect(mockExecuteCommand).toHaveBeenCalledWith( + 'conn-1', + "git diff --no-color --unified=2000 'abc123' HEAD -- 'file.ts'", + '/home/user/project' + ); + }); + + it('returns empty text diff for unchanged tracked files', async () => { + mockExecuteCommand + .mockResolvedValueOnce({ + stdout: '__EMDASH_CONTENT__\nsame\n', + stderr: '', + exitCode: 0, + } as ExecResult) // HEAD:file + .mockResolvedValueOnce({ + stdout: '__EMDASH_CONTENT__\nsame\n', + stderr: '', + exitCode: 0, + } as ExecResult) // working file + .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); // git diff + + const result = await service.getFileDiff('conn-1', '/home/user/project', 'file.ts'); + + expect(result.mode).toBe('text'); + expect(result.lines).toEqual([]); + expect(result.originalContent).toBe('same'); + expect(result.modifiedContent).toBe('same'); }); }); - describe('stageFile', () => { - it('should stage a file via git add', async () => { + describe('updateIndex', () => { + it('should stage selected files via git add', async () => { mockExecuteCommand.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); - await service.stageFile('conn-1', '/home/user/project', 'src/app.ts'); + await service.updateIndex('conn-1', '/home/user/project', { + action: 'stage', + scope: 'paths', + filePaths: ['src/app.ts'], + }); expect(mockExecuteCommand).toHaveBeenCalledWith( 'conn-1', @@ -726,7 +916,7 @@ describe('RemoteGitService', () => { ); }); - it('should throw on failure', async () => { + it('should throw on stage failure', async () => { mockExecuteCommand.mockResolvedValue({ stdout: '', stderr: 'fatal: pathspec not found', @@ -734,42 +924,53 @@ describe('RemoteGitService', () => { } as ExecResult); await expect( - service.stageFile('conn-1', '/home/user/project', 'nonexistent.ts') + service.updateIndex('conn-1', '/home/user/project', { + action: 'stage', + scope: 'paths', + filePaths: ['nonexistent.ts'], + }) ).rejects.toThrow('Failed to stage file'); }); - it('should escape special characters in file path', async () => { + it('should stage all files via git add -A', async () => { mockExecuteCommand.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); - await service.stageFile('conn-1', '/home/user/project', "file with spaces & 'quotes'.ts"); + await service.updateIndex('conn-1', '/home/user/project', { + action: 'stage', + scope: 'all', + }); - expect(mockExecuteCommand).toHaveBeenCalledWith( - 'conn-1', - expect.stringContaining('git add --'), - '/home/user/project' - ); + expect(mockExecuteCommand).toHaveBeenCalledWith('conn-1', 'git add -A', '/home/user/project'); }); - }); - describe('stageAllFiles', () => { - it('should run git add -A', async () => { + it('should unstage selected files via git reset HEAD', async () => { mockExecuteCommand.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); - await service.stageAllFiles('conn-1', '/home/user/project'); + await service.updateIndex('conn-1', '/home/user/project', { + action: 'unstage', + scope: 'paths', + filePaths: ['src/app.ts'], + }); - expect(mockExecuteCommand).toHaveBeenCalledWith('conn-1', 'git add -A', '/home/user/project'); + expect(mockExecuteCommand).toHaveBeenCalledWith( + 'conn-1', + "git reset HEAD -- 'src/app.ts'", + '/home/user/project' + ); }); - }); - describe('unstageFile', () => { - it('should run git reset HEAD', async () => { + it('should escape special characters in file path', async () => { mockExecuteCommand.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); - await service.unstageFile('conn-1', '/home/user/project', 'src/app.ts'); + await service.updateIndex('conn-1', '/home/user/project', { + action: 'stage', + scope: 'paths', + filePaths: ["file with spaces & 'quotes'.ts"], + }); expect(mockExecuteCommand).toHaveBeenCalledWith( 'conn-1', - "git reset HEAD -- 'src/app.ts'", + expect.stringContaining('git add --'), '/home/user/project' ); }); diff --git a/src/main/services/fs/LocalFileSystem.ts b/src/main/services/fs/LocalFileSystem.ts index 578dced443..a252383bbb 100644 --- a/src/main/services/fs/LocalFileSystem.ts +++ b/src/main/services/fs/LocalFileSystem.ts @@ -594,4 +594,45 @@ export class LocalFileSystem implements IFileSystem { return { success: false, error: err.message }; } } + + /** + * Rename a file or directory + */ + async rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> { + try { + const fullOldPath = this.resolvePath(oldPath); + const fullNewPath = this.resolvePath(newPath); + + try { + await fs.stat(fullOldPath); + } catch { + return { success: false, error: 'Source does not exist' }; + } + + try { + await fs.stat(fullNewPath); + return { success: false, error: 'Destination already exists' }; + } catch { + // Destination doesn't exist - good + } + + await fs.rename(fullOldPath, fullNewPath); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; + } + } + + /** + * Create a directory + */ + async mkdir(dirPath: string): Promise<{ success: boolean; error?: string }> { + try { + const fullPath = this.resolvePath(dirPath); + await fs.mkdir(fullPath, { recursive: true }); + return { success: true }; + } catch (err: any) { + return { success: false, error: err.message }; + } + } } diff --git a/src/main/services/fs/RemoteFileSystem.ts b/src/main/services/fs/RemoteFileSystem.ts index 1f05359487..acdbdeef16 100644 --- a/src/main/services/fs/RemoteFileSystem.ts +++ b/src/main/services/fs/RemoteFileSystem.ts @@ -549,6 +549,41 @@ export class RemoteFileSystem implements IFileSystem { return { success: false, error: message }; } } + /** + * Rename a file or directory + */ + async rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> { + try { + const sftp = await this.sshService.getSftp(this.connectionId); + const fullOldPath = this.resolveRemotePath(oldPath); + const fullNewPath = this.resolveRemotePath(newPath); + return new Promise((resolve) => { + sftp.rename(fullOldPath, fullNewPath, (err) => { + if (err) { + resolve({ success: false, error: err.message }); + } else { + resolve({ success: true }); + } + }); + }); + } catch (error) { + return { success: false, error: String(error) }; + } + } + /** + * Create a directory + */ + async mkdir(dirPath: string): Promise<{ success: boolean; error?: string }> { + try { + const sftp = await this.sshService.getSftp(this.connectionId); + const fullPath = this.resolveRemotePath(dirPath); + + await this.ensureRemoteDir(sftp, fullPath); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + } /** * Read image file as base64 data URL via SFTP diff --git a/src/main/services/fs/types.ts b/src/main/services/fs/types.ts index a34047fe2d..b9602b9b42 100644 --- a/src/main/services/fs/types.ts +++ b/src/main/services/fs/types.ts @@ -183,6 +183,14 @@ export interface IFileSystem { */ remove?(path: string): Promise<{ success: boolean; error?: string }>; + /** + * Rename a file or directory + */ + rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }>; + /** + * Create a directory + */ + mkdir(path: string): Promise<{ success: boolean; error?: string }>; /** * Read image file as base64 data URL * @param path - Image file path relative to project root diff --git a/src/main/services/fsIpc.ts b/src/main/services/fsIpc.ts index 71b26107c0..33f5dd166d 100644 --- a/src/main/services/fsIpc.ts +++ b/src/main/services/fsIpc.ts @@ -879,4 +879,145 @@ export function registerFsIpc(): void { } } ); + + // Ensure entries exist in .gitignore (idempotent) + ipcMain.handle( + 'fs:ensureGitignore', + async (_event, args: { projectPath: string; patterns: string[] }) => { + try { + const { projectPath, patterns } = args; + if (!projectPath || !fs.existsSync(projectPath) || patterns.length === 0) { + return { success: true }; + } + + const gitignorePath = path.join(projectPath, '.gitignore'); + let content = ''; + if (fs.existsSync(gitignorePath)) { + content = fs.readFileSync(gitignorePath, 'utf8'); + } + + const existingLines = new Set(content.split('\n').map((line) => line.trim())); + const toAdd = patterns.filter((p) => !existingLines.has(p)); + if (toAdd.length === 0) return { success: true }; + + const suffix = + (content.length > 0 && !content.endsWith('\n') ? '\n' : '') + + '\n# Workspace provider scripts (added by Emdash)\n' + + toAdd.join('\n') + + '\n'; + + fs.writeFileSync(gitignorePath, content + suffix, 'utf8'); + return { success: true }; + } catch (error) { + console.error('fs:ensureGitignore failed:', error); + return { success: false, error: 'Failed to update .gitignore' }; + } + } + ); + + // rename a file or directory + + ipcMain.handle( + 'fs:rename', + async (_event, args: { root: string; oldName: string; newName: string } & RemoteParams) => { + try { + if (isRemoteRequest(args)) { + try { + const rfs = createRemoteFs(args); + const oldPath = path.posix.join(args.remotePath, args.oldName); + const newPath = path.posix.join(args.remotePath, args.newName); + return await rfs.rename(oldPath, newPath); + } catch (error) { + console.error('fs:rename failed:', error); + return { success: false, error: 'Failed to rename file or directory' }; + } + } + + // local path + + const { root, oldName, newName } = args; + if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' }; + if (!oldName || !newName) return { success: false, error: 'Invalid file names' }; + const oldAbs = path.resolve(root, oldName); + const newAbs = path.resolve(root, newName); + const normRoot = path.resolve(root) + path.sep; + if (!oldAbs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' }; + if (!newAbs.startsWith(normRoot)) return { success: false, error: 'New path escapes root' }; + if (!fs.existsSync(oldAbs)) return { success: false, error: 'Source does not exist' }; + if (fs.existsSync(newAbs)) return { success: false, error: 'Destination already exists' }; + fs.renameSync(oldAbs, newAbs); + return { success: true }; + } catch (error) { + console.error('fs:rename failed:', error); + return { success: false, error: 'Failed to rename file or directory' }; + } + } + ); + + // Create a directory + ipcMain.handle( + 'fs:mkdir', + async (_event, args: { root: string; relPath: string } & RemoteParams) => { + try { + // --- Remote path --- + if (isRemoteRequest(args)) { + try { + const rfs = createRemoteFs(args); + const targetPath = path.posix.join(args.remotePath, args.relPath); + return await rfs.mkdir(targetPath); + } catch (error) { + console.error('fs:mkdir remote failed:', error); + return { success: false, error: 'Failed to create remote directory' }; + } + } + // --- Local path --- + const { root, relPath } = args; + if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' }; + if (!relPath) return { success: false, error: 'Invalid path' }; + const abs = path.resolve(root, relPath); + const normRoot = path.resolve(root) + path.sep; + if (!abs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' }; + fs.mkdirSync(abs, { recursive: true }); + return { success: true }; + } catch (error) { + console.error('fs:mkdir failed:', error); + return { success: false, error: 'Failed to create directory' }; + } + } + ); + + // Remove a directory (recursive) + ipcMain.handle( + 'fs:rmdir', + async (_event, args: { root: string; relPath: string } & RemoteParams) => { + try { + // --- Remote path --- + if (isRemoteRequest(args)) { + try { + const rfs = createRemoteFs(args); + const targetPath = path.posix.join(args.remotePath, args.relPath); + return await rfs.remove(targetPath); + } catch (error) { + console.error('fs:rmdir remote failed:', error); + return { success: false, error: 'Failed to remove remote directory' }; + } + } + // --- Local path --- + const { root, relPath } = args; + if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' }; + if (!relPath) return { success: false, error: 'Invalid path' }; + const abs = path.resolve(root, relPath); + const normRoot = path.resolve(root) + path.sep; + if (!abs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' }; + if (!fs.existsSync(abs)) return { success: true }; + const st = safeStat(abs); + if (!st || !st.isDirectory()) return { success: false, error: 'Not a directory' }; + fs.rmSync(abs, { recursive: true, force: true }); + return { success: true }; + } catch (error) { + console.error('fs:rmdir failed:', error); + return { success: false, error: 'Failed to remove directory' }; + } + } + ); } diff --git a/src/main/services/git-core/diffShared.ts b/src/main/services/git-core/diffShared.ts new file mode 100644 index 0000000000..6a1a19980e --- /dev/null +++ b/src/main/services/git-core/diffShared.ts @@ -0,0 +1,31 @@ +import { buildDiffWarnings } from '../../utils/diffParser'; +import type { DiffResult } from '../../utils/diffParser'; + +export type CappedTextResult = { + exists: boolean; + tooLarge: boolean; + content?: string; + isBinary?: boolean; +}; + +export function isMaxBufferError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return (error as NodeJS.ErrnoException).code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'; +} + +export function buildOptionalDiffWarnings( + originalContent: string | undefined, + modifiedContent: string | undefined, + lines: DiffResult['lines'] +): DiffResult['warnings'] { + const warnings = buildDiffWarnings({ originalContent, modifiedContent, lines }); + return warnings.length > 0 ? warnings : undefined; +} + +export function buildAddedDiffLines(content: string): DiffResult['lines'] { + return content.split('\n').map((line) => ({ right: line, type: 'add' as const })); +} + +export function buildDeletedDiffLines(content: string): DiffResult['lines'] { + return content.split('\n').map((line) => ({ left: line, type: 'del' as const })); +} diff --git a/src/main/services/git-core/indexShared.ts b/src/main/services/git-core/indexShared.ts new file mode 100644 index 0000000000..a3aa51ce97 --- /dev/null +++ b/src/main/services/git-core/indexShared.ts @@ -0,0 +1,56 @@ +import type { GitIndexUpdateArgs } from '../../../shared/git/types'; + +export type UpdateIndexOps = { + stageAll: () => Promise; + resetAll: () => Promise; + listStagedPaths: () => Promise; + stagePaths: (filePaths: string[]) => Promise; + resetPaths: (filePaths: string[]) => Promise; + resetPath: (filePath: string) => Promise; + removePathFromIndex: (filePath: string) => Promise; +}; + +async function unstagePathsWithFallback(filePaths: string[], ops: UpdateIndexOps): Promise { + if (filePaths.length <= 0) return; + + if (await ops.resetPaths(filePaths)) { + return; + } + + for (const filePath of filePaths) { + if (await ops.resetPath(filePath)) { + continue; + } + await ops.removePathFromIndex(filePath); + } +} + +export async function updateIndexShared( + args: GitIndexUpdateArgs, + ops: UpdateIndexOps +): Promise { + if (args.scope === 'all') { + if (args.action === 'stage') { + await ops.stageAll(); + return; + } + + if (await ops.resetAll()) { + return; + } + + const stagedPaths = await ops.listStagedPaths(); + await unstagePathsWithFallback(stagedPaths, ops); + return; + } + + const filePaths = (args.filePaths || []).filter(Boolean); + if (filePaths.length <= 0) return; + + if (args.action === 'stage') { + await ops.stagePaths(filePaths); + return; + } + + await unstagePathsWithFallback(filePaths, ops); +} diff --git a/src/main/services/git-core/remoteTaggedContent.ts b/src/main/services/git-core/remoteTaggedContent.ts new file mode 100644 index 0000000000..e02cdb5715 --- /dev/null +++ b/src/main/services/git-core/remoteTaggedContent.ts @@ -0,0 +1,41 @@ +import type { ExecResult } from '../../../shared/ssh/types'; +import { stripTrailingNewline } from '../../utils/diffParser'; +import type { CappedTextResult } from './diffShared'; + +const CONTENT_PREFIX = '__EMDASH_CONTENT__'; +const MISSING_MARKER = '__EMDASH_MISSING__'; +const TOO_LARGE_MARKER = '__EMDASH_TOO_LARGE__'; + +export function parseTaggedRemoteContent(result: ExecResult): CappedTextResult { + if (result.exitCode !== 0) { + return { exists: false, tooLarge: false }; + } + + const output = stripTrailingNewline(result.stdout || ''); + if (output === MISSING_MARKER) { + return { exists: false, tooLarge: false }; + } + if (output === TOO_LARGE_MARKER) { + return { exists: true, tooLarge: true }; + } + if (output.startsWith(`${CONTENT_PREFIX}\n`)) { + const content = output.slice(`${CONTENT_PREFIX}\n`.length); + if (content.includes('\0')) { + return { exists: true, tooLarge: false, isBinary: true }; + } + return { + exists: true, + tooLarge: false, + content, + }; + } + if (output === CONTENT_PREFIX) { + return { exists: true, tooLarge: false, content: '' }; + } + + if (output.includes('\0')) { + return { exists: true, tooLarge: false, isBinary: true }; + } + + return { exists: true, tooLarge: false, content: output }; +} diff --git a/src/main/services/git-core/revertShared.ts b/src/main/services/git-core/revertShared.ts new file mode 100644 index 0000000000..2b4b755cd9 --- /dev/null +++ b/src/main/services/git-core/revertShared.ts @@ -0,0 +1,21 @@ +export type RevertFileOps = { + normalizeFilePath: (filePath: string) => string; + existsInHead: (filePath: string) => Promise; + deleteUntracked: (filePath: string) => Promise; + checkoutHead: (filePath: string) => Promise; +}; + +export async function revertFileShared( + filePath: string, + ops: RevertFileOps +): Promise<{ action: 'reverted' }> { + const safePath = ops.normalizeFilePath(filePath); + const existsInHead = await ops.existsInHead(safePath); + if (!existsInHead) { + await ops.deleteUntracked(safePath); + return { action: 'reverted' }; + } + + await ops.checkoutHead(safePath); + return { action: 'reverted' }; +} diff --git a/src/main/services/git-core/statusShared.ts b/src/main/services/git-core/statusShared.ts new file mode 100644 index 0000000000..262f73a3d6 --- /dev/null +++ b/src/main/services/git-core/statusShared.ts @@ -0,0 +1,65 @@ +import type { GitChange } from '../../../shared/git/types'; +import { combineNumstatValues } from '../../utils/gitStatusParser'; +import type { ParsedGitStatusEntry, ParsedNumstat } from '../../utils/gitStatusParser'; + +export const MAX_UNTRACKED_LINECOUNT_BYTES = 512 * 1024; + +type NumstatMap = Map; + +export function buildStatusChanges( + entries: ParsedGitStatusEntry[], + stagedStats: NumstatMap, + unstagedStats: NumstatMap +): { changes: GitChange[]; untrackedPathsNeedingCounts: string[] } { + const changes: GitChange[] = []; + const untrackedPathsNeedingCounts: string[] = []; + + for (const entry of entries) { + const staged = stagedStats.get(entry.path); + const unstaged = unstagedStats.get(entry.path); + const additions = combineNumstatValues(staged?.additions, unstaged?.additions); + const deletions = combineNumstatValues(staged?.deletions, unstaged?.deletions); + + if (entry.statusCode.includes('?') && additions === 0 && deletions === 0) { + untrackedPathsNeedingCounts.push(entry.path); + } + + changes.push({ + path: entry.path, + status: entry.status, + additions, + deletions, + isStaged: entry.isStaged, + }); + } + + return { changes, untrackedPathsNeedingCounts }; +} + +export function applyUntrackedLineCounts( + changes: GitChange[], + countsByPath: Map +): GitChange[] { + if (countsByPath.size === 0 || changes.length === 0) { + return changes; + } + + const changeIndexByPath = new Map(); + for (let i = 0; i < changes.length; i++) { + changeIndexByPath.set(changes[i].path, i); + } + + for (const [filePath, count] of countsByPath) { + const index = changeIndexByPath.get(filePath); + if (index === undefined) continue; + if (count === null) { + changes[index].additions = null; + changes[index].deletions = null; + continue; + } + changes[index].additions = count; + changes[index].deletions = 0; + } + + return changes; +} diff --git a/src/main/services/git-core/workingTreeDiffShared.ts b/src/main/services/git-core/workingTreeDiffShared.ts new file mode 100644 index 0000000000..0bbb71348e --- /dev/null +++ b/src/main/services/git-core/workingTreeDiffShared.ts @@ -0,0 +1,130 @@ +import type { DiffResult } from '../../utils/diffParser'; +import { + buildAddedDiffLines, + buildDeletedDiffLines, + buildOptionalDiffWarnings, + type CappedTextResult, +} from './diffShared'; + +type WorkingTreeDiffResolutionInput = { + diffStdout?: string; + diffLines: DiffResult['lines']; + hasHunk: boolean; + diffTooLarge: boolean; + diffFailed: boolean; + original: CappedTextResult; + modified: CappedTextResult; +}; + +export function resolveWorkingTreeDiffResult({ + diffStdout, + diffLines, + hasHunk, + diffTooLarge, + diffFailed, + original, + modified, +}: WorkingTreeDiffResolutionInput): DiffResult { + if (original.isBinary || modified.isBinary) { + return { lines: [], mode: 'binary', isBinary: true }; + } + + const originalContent = original.content; + const modifiedContent = modified.content; + + if (diffStdout !== undefined) { + const warnings = buildOptionalDiffWarnings(originalContent, modifiedContent, diffLines); + const hasLargeContent = original.tooLarge || modified.tooLarge; + + if (diffTooLarge || hasLargeContent) { + return { + lines: diffLines, + mode: 'largeText', + originalContent, + modifiedContent, + warnings, + }; + } + + if (diffLines.length === 0) { + if (modifiedContent !== undefined && !original.exists) { + const renderedLines = buildAddedDiffLines(modifiedContent); + return { + lines: renderedLines, + mode: 'text', + modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, renderedLines), + }; + } + if (originalContent !== undefined && !modified.exists) { + const renderedLines = buildDeletedDiffLines(originalContent); + return { + lines: renderedLines, + mode: 'text', + originalContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, renderedLines), + }; + } + if ( + !hasHunk && + diffStdout.trim() && + originalContent === undefined && + modifiedContent === undefined + ) { + return { + lines: [], + mode: 'unrenderable', + }; + } + + return { + lines: [], + mode: 'text', + originalContent, + modifiedContent, + warnings, + }; + } + + return { + lines: diffLines, + mode: 'text', + originalContent, + modifiedContent, + warnings, + }; + } + + if (diffTooLarge || original.tooLarge || modified.tooLarge) { + return { + lines: [], + mode: 'largeText', + originalContent, + modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, []), + }; + } + + if (modifiedContent !== undefined) { + const lines = buildAddedDiffLines(modifiedContent); + return { + lines, + mode: 'text', + originalContent, + modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, lines), + }; + } + if (originalContent !== undefined) { + const lines = buildDeletedDiffLines(originalContent); + return { + lines, + mode: 'text', + originalContent, + modifiedContent, + warnings: buildOptionalDiffWarnings(originalContent, modifiedContent, lines), + }; + } + const fallbackMode: DiffResult['mode'] = diffFailed ? 'unrenderable' : 'text'; + return { lines: [], mode: fallbackMode }; +} diff --git a/src/main/services/lifecycleIpc.ts b/src/main/services/lifecycleIpc.ts index 92922d559a..38e13c3a6b 100644 --- a/src/main/services/lifecycleIpc.ts +++ b/src/main/services/lifecycleIpc.ts @@ -81,15 +81,31 @@ export function registerLifecycleIpc(): void { } ); - ipcMain.handle('lifecycle:run:stop', async (_event, args: { taskId: string }) => { - try { - const result = taskLifecycleService.stopRun(args.taskId); - return { success: result.ok, ...result }; - } catch (error) { - log.error('Failed to stop run lifecycle phase:', error); - return { success: false, error: (error as Error).message }; + ipcMain.handle( + 'lifecycle:run:stop', + async ( + _event, + args: { + taskId: string; + taskPath?: string; + projectPath?: string; + taskName?: string; + } + ) => { + try { + const result = await taskLifecycleService.stopRun( + args.taskId, + args.taskPath, + args.projectPath, + args.taskName + ); + return { success: result.ok, ...result }; + } catch (error) { + log.error('Failed to stop run lifecycle phase:', error); + return { success: false, error: (error as Error).message }; + } } - }); + ); ipcMain.handle( 'lifecycle:teardown', diff --git a/src/main/services/mcp/adapters.ts b/src/main/services/mcp/adapters.ts new file mode 100644 index 0000000000..23af6488e8 --- /dev/null +++ b/src/main/services/mcp/adapters.ts @@ -0,0 +1,236 @@ +import type { AdapterType, ServerMap, RawServerEntry } from '@shared/mcp/types'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function isHttpServer(s: RawServerEntry): boolean { + return s.type === 'http'; +} + +function isStdio(s: RawServerEntry): boolean { + return !isHttpServer(s) && s.command !== undefined; +} + +const INJECTED_ACCEPT = 'application/json, text/event-stream'; + +function ensureHeader(headers: Record, key: string, val: string): void { + if (typeof headers[key] !== 'string') { + headers[key] = val; + } +} + +function stripInjectedHeaders(entry: RawServerEntry): void { + if (typeof entry.headers !== 'object' || entry.headers === null) return; + const headers = entry.headers as Record; + if (headers.Accept === INJECTED_ACCEPT) { + delete headers.Accept; + if (!Object.keys(headers).length) { + delete entry.headers; + } + } +} + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +function transformHttpServers( + servers: ServerMap, + fn: (s: RawServerEntry) => RawServerEntry +): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v === 'object' && v !== null && isHttpServer(v)) { + result[k] = fn(deepClone(v)); + } else { + result[k] = deepClone(v); + } + } + return result; +} + +// ── Forward Adapters (canonical → agent) ─────────────────────────────────── + +function fwdPassthrough(servers: ServerMap): ServerMap { + return deepClone(servers); +} + +function fwdGemini(servers: ServerMap): ServerMap { + return transformHttpServers(servers, (s) => { + const url = s.url ?? ''; + const headers: Record = { + ...((s.headers as Record) ?? {}), + }; + ensureHeader(headers, 'Accept', 'application/json, text/event-stream'); + const result: RawServerEntry = { httpUrl: url, headers }; + if (s.env && typeof s.env === 'object') result.env = s.env; + return result; + }); +} + +function fwdCursor(servers: ServerMap): ServerMap { + return transformHttpServers(servers, (s) => { + const url = s.url ?? ''; + const headers = s.headers ?? {}; + const result: RawServerEntry = { url, headers }; + if (s.env && typeof s.env === 'object') result.env = s.env; + return result; + }); +} + +function fwdCodex(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v === 'object' && v !== null && isStdio(v)) { + result[k] = deepClone(v); + } + } + return result; +} + +function fwdOpencode(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v !== 'object' || v === null) { + result[k] = v; + continue; + } + if (isHttpServer(v)) { + const headers: Record = { + ...((v.headers as Record) ?? {}), + }; + ensureHeader(headers, 'Accept', 'application/json, text/event-stream'); + const entry: RawServerEntry = { type: 'remote', url: v.url ?? '', headers, enabled: true }; + if (v.env && typeof v.env === 'object') entry.env = v.env; + result[k] = entry; + } else if (isStdio(v)) { + const cmdVec: string[] = []; + if (typeof v.command === 'string' && v.command) cmdVec.push(v.command); + if (Array.isArray(v.args)) cmdVec.push(...(v.args as string[])); + const entry: RawServerEntry = { type: 'local', command: cmdVec, enabled: true }; + if (v.env && typeof v.env === 'object') entry.env = v.env; + result[k] = entry; + } else { + result[k] = deepClone(v); + } + } + return result; +} + +function fwdCopilot(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v === 'object' && v !== null && !('tools' in v)) { + result[k] = { ...deepClone(v), tools: ['*'] }; + } else { + result[k] = deepClone(v); + } + } + return result; +} + +// ── Reverse Adapters (agent → canonical) ─────────────────────────────────── + +function revPassthrough(servers: ServerMap): ServerMap { + return deepClone(servers); +} + +function revGemini(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v === 'object' && v !== null && 'httpUrl' in v) { + const { httpUrl, ...rest } = v; + const entry = { ...rest, type: 'http', url: httpUrl } as RawServerEntry; + stripInjectedHeaders(entry); + result[k] = entry; + } else { + result[k] = deepClone(v); + } + } + return result; +} + +function revCursor(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v === 'object' && v !== null && 'url' in v && !('command' in v)) { + result[k] = { ...deepClone(v), type: 'http' }; + } else { + result[k] = deepClone(v); + } + } + return result; +} + +function revCodex(servers: ServerMap): ServerMap { + return deepClone(servers); +} + +function revOpencode(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v !== 'object' || v === null) { + result[k] = v; + continue; + } + if (v.type === 'remote') { + const { type: _, enabled: _e, ...rest } = v; + const entry = { ...rest, type: 'http' } as RawServerEntry; + stripInjectedHeaders(entry); + result[k] = entry; + } else if (v.type === 'local' && Array.isArray(v.command)) { + const cmdArr = v.command as string[]; + const [command, ...args] = cmdArr; + const entry: RawServerEntry = {}; + if (command) entry.command = command; + if (args.length) entry.args = args; + result[k] = entry; + } else { + result[k] = deepClone(v); + } + } + return result; +} + +function revCopilot(servers: ServerMap): ServerMap { + const result: ServerMap = {}; + for (const [k, v] of Object.entries(servers)) { + if (typeof v === 'object' && v !== null) { + const clone = deepClone(v); + if (Array.isArray(clone.tools) && clone.tools.length === 1 && clone.tools[0] === '*') { + delete clone.tools; + } + result[k] = clone; + } else { + result[k] = v; + } + } + return result; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +const FORWARD: Record ServerMap> = { + passthrough: fwdPassthrough, + gemini: fwdGemini, + cursor: fwdCursor, + codex: fwdCodex, + opencode: fwdOpencode, + copilot: fwdCopilot, +}; + +const REVERSE: Record ServerMap> = { + passthrough: revPassthrough, + gemini: revGemini, + cursor: revCursor, + codex: revCodex, + opencode: revOpencode, + copilot: revCopilot, +}; + +export function adaptForward(adapter: AdapterType, servers: ServerMap): ServerMap { + return FORWARD[adapter](servers); +} + +export function adaptReverse(adapter: AdapterType, servers: ServerMap): ServerMap { + return REVERSE[adapter](servers); +} diff --git a/src/main/services/mcp/catalog.ts b/src/main/services/mcp/catalog.ts new file mode 100644 index 0000000000..afeda3a4f2 --- /dev/null +++ b/src/main/services/mcp/catalog.ts @@ -0,0 +1,17 @@ +import type { McpCatalogEntry, RawServerEntry } from '@shared/mcp/types'; +import { catalogData } from '@shared/mcp/catalog'; + +export function loadCatalog(): McpCatalogEntry[] { + return Object.entries(catalogData).map(([key, entry]) => ({ + key, + name: entry.name, + description: entry.description, + docsUrl: entry.docsUrl, + defaultConfig: entry.config, + credentialKeys: entry.credentialKeys, + })); +} + +export function getCatalogServerConfig(key: string): RawServerEntry | undefined { + return catalogData[key]?.config; +} diff --git a/src/main/services/mcp/configIO.ts b/src/main/services/mcp/configIO.ts new file mode 100644 index 0000000000..0fce1fe564 --- /dev/null +++ b/src/main/services/mcp/configIO.ts @@ -0,0 +1,155 @@ +import * as fs from 'fs/promises'; +import path from 'path'; +import * as jsoncParser from 'jsonc-parser'; +import * as toml from 'smol-toml'; +import { log } from '../../lib/logger'; +import type { AgentMcpMeta, ServerMap, RawServerEntry } from '@shared/mcp/types'; + +function isJsoncConfig(meta: AgentMcpMeta): boolean { + return meta.isJsonc === true || meta.configPath.endsWith('.jsonc'); +} + +function cloneTemplate(template: Record): Record { + return JSON.parse(JSON.stringify(template)) as Record; +} + +const JSONC_PARSE_OPTIONS: jsoncParser.ParseOptions = { + allowTrailingComma: true, + disallowComments: false, +}; + +function parseJsoncConfig(meta: AgentMcpMeta, content: string): Record { + const errors: jsoncParser.ParseError[] = []; + const parsed = jsoncParser.parse(content, errors, JSONC_PARSE_OPTIONS); + + if (errors.length > 0 || typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + const details = + errors.length > 0 + ? errors.map((error) => jsoncParser.printParseErrorCode(error.error)).join(', ') + : 'root value must be an object'; + throw new Error(`Failed to safely parse JSONC config at ${meta.configPath}: ${details}`); + } + + return parsed as Record; +} + +// ── Read ─────────────────────────────────────────────────────────────────── + +export async function readServers(meta: AgentMcpMeta): Promise { + let content: string; + try { + content = await fs.readFile(meta.configPath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw err; + } + + if (!content.trim()) return {}; + + let parsed: Record; + if (meta.isToml) { + parsed = toml.parse(content) as Record; + } else if (isJsoncConfig(meta)) { + parsed = parseJsoncConfig(meta, content); + } else { + try { + parsed = JSON.parse(content); + } catch { + log.warn(`Invalid JSON in ${meta.configPath}, returning empty`); + return {}; + } + } + + return extractAtPath(parsed, meta.serversPath); +} + +function extractAtPath(obj: Record, pathSegments: string[]): ServerMap { + let current: unknown = obj; + for (const key of pathSegments) { + if (typeof current !== 'object' || current === null) return {}; + current = (current as Record)[key]; + if (current === undefined) return {}; + } + if (typeof current !== 'object' || current === null || Array.isArray(current)) return {}; + // Filter out non-object entries and the "meta" key + const result: ServerMap = {}; + for (const [k, v] of Object.entries(current as Record)) { + if (typeof v === 'object' && v !== null && !Array.isArray(v)) { + result[k] = v as RawServerEntry; + } + } + return result; +} + +// ── Write ────────────────────────────────────────────────────────────────── + +export async function writeServers(meta: AgentMcpMeta, servers: ServerMap): Promise { + // Ensure parent directory exists + await fs.mkdir(path.dirname(meta.configPath), { recursive: true }); + + // Read existing config or use template + let existing: Record; + let existingRaw: string | undefined; + try { + existingRaw = await fs.readFile(meta.configPath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + + if (meta.isToml) { + existing = existingRaw + ? (toml.parse(existingRaw) as Record) + : cloneTemplate(meta.template); + setAtPath(existing, meta.serversPath, servers); + await fs.writeFile( + meta.configPath, + toml.stringify(existing as Parameters[0]) + ); + return; + } + + if (isJsoncConfig(meta)) { + if (existingRaw && existingRaw.trim()) { + // Validate/parse JSONC before computing edits; return value intentionally ignored + // so jsonc-parser.modify/applyEdits can preserve the original comments/formatting. + parseJsoncConfig(meta, existingRaw); + const edits = jsoncParser.modify(existingRaw, meta.serversPath, servers, {}); + const modified = jsoncParser.applyEdits(existingRaw, edits); + await fs.writeFile(meta.configPath, modified); + return; + } + + existing = cloneTemplate(meta.template); + setAtPath(existing, meta.serversPath, servers); + await fs.writeFile(meta.configPath, JSON.stringify(existing, null, 2)); + return; + } + + // Plain JSON + if (existingRaw) { + try { + existing = JSON.parse(existingRaw); + } catch { + log.warn(`Invalid JSON in ${meta.configPath}, resetting to template`); + existing = cloneTemplate(meta.template); + } + } else { + existing = cloneTemplate(meta.template); + } + setAtPath(existing, meta.serversPath, servers); + await fs.writeFile(meta.configPath, JSON.stringify(existing, null, 2)); +} + +function setAtPath(obj: Record, pathSegments: string[], value: unknown): void { + let current: Record = obj; + for (let i = 0; i < pathSegments.length - 1; i++) { + const key = pathSegments[i]; + if (typeof current[key] !== 'object' || current[key] === null) { + current[key] = {}; + } + current = current[key] as Record; + } + if (pathSegments.length > 0) { + current[pathSegments[pathSegments.length - 1]] = value; + } +} diff --git a/src/main/services/mcp/configPaths.ts b/src/main/services/mcp/configPaths.ts new file mode 100644 index 0000000000..52d804ca00 --- /dev/null +++ b/src/main/services/mcp/configPaths.ts @@ -0,0 +1,113 @@ +import os from 'os'; +import path from 'path'; +import type { AgentMcpMeta, AdapterType } from '@shared/mcp/types'; + +interface AgentConfigDef { + pathSegments: string[]; + serversPath: string[]; + template: Record; + isToml: boolean; + isJsonc?: boolean; + adapter: AdapterType; + supportsHttp: boolean; +} + +const AGENT_CONFIGS: Record = { + claude: { + pathSegments: ['.claude.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'passthrough', + supportsHttp: true, + }, + cursor: { + pathSegments: ['.cursor', 'mcp.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'cursor', + supportsHttp: true, + }, + codex: { + pathSegments: ['.codex', 'config.toml'], + serversPath: ['mcp_servers'], + template: { mcp_servers: {} }, + isToml: true, + adapter: 'codex', + supportsHttp: false, + }, + amp: { + pathSegments: ['.config', 'amp', 'settings.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'passthrough', + supportsHttp: true, + }, + gemini: { + pathSegments: ['.gemini', 'settings.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'gemini', + supportsHttp: true, + }, + qwen: { + pathSegments: ['.qwen', 'settings.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'gemini', + supportsHttp: true, + }, + opencode: { + pathSegments: ['.config', 'opencode', 'opencode.json'], + serversPath: ['mcp'], + template: { mcp: {} }, + isToml: false, + isJsonc: true, + adapter: 'opencode', + supportsHttp: true, + }, + copilot: { + pathSegments: ['.copilot', 'mcp-config.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'copilot', + supportsHttp: true, + }, + droid: { + pathSegments: ['.droid', 'settings.json'], + serversPath: ['mcpServers'], + template: { mcpServers: {} }, + isToml: false, + adapter: 'passthrough', + supportsHttp: true, + }, +}; + +export function getAgentMcpMeta(agentId: string): AgentMcpMeta | undefined { + const def = AGENT_CONFIGS[agentId]; + if (!def) return undefined; + + const home = os.homedir(); + return { + agentId, + configPath: path.join(home, ...def.pathSegments), + serversPath: def.serversPath, + template: def.template, + isToml: def.isToml, + isJsonc: def.isJsonc, + adapter: def.adapter, + }; +} + +export function getAllMcpAgentIds(): string[] { + return Object.keys(AGENT_CONFIGS); +} + +export function agentSupportsHttp(agentId: string): boolean { + return AGENT_CONFIGS[agentId]?.supportsHttp ?? true; +} diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index df8b4b4c62..4492d739ec 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -12,10 +12,14 @@ import { setOnDirectCliExit, parseShellArgs, buildProviderCliArgs, + getProviderRuntimeCliArgs, resolveProviderCommandConfig, killTmuxSession, getTmuxSessionName, getPtyTmuxSessionName, + clearStoredSession, + getStoredResumeTarget, + markCodexSessionBound, } from './ptyManager'; import { log } from '../lib/logger'; import { terminalSnapshotService } from './TerminalSnapshotService'; @@ -28,7 +32,9 @@ import { detectAndLoadTerminalConfig } from './TerminalConfigParser'; import { ClaudeHookService } from './ClaudeHookService'; import { databaseService } from './DatabaseService'; import { lifecycleScriptsService } from './LifecycleScriptsService'; +import { taskLifecycleService } from './TaskLifecycleService'; import { maybeAutoTrustForClaude } from './ClaudeConfigService'; +import { OpenCodeHookService, OPEN_CODE_PLUGIN_FILE } from './OpenCodeHookService'; import { getDrizzleClient } from '../db/drizzleClient'; import { sshConnections as sshConnectionsTable } from '../db/schema'; import { eq } from 'drizzle-orm'; @@ -37,9 +43,49 @@ import { createHash, randomUUID } from 'crypto'; import path from 'path'; import { quoteShellArg } from '../utils/shellEscape'; import { agentEventService } from './AgentEventService'; +import { codexSessionService } from './CodexSessionService'; +import { waitForShellPrompt, type PromptWaitHandle } from '../utils/waitForShellPrompt'; const owners = new Map(); const listeners = new Set(); +const promptHandles = new Map(); + +function cancelPromptHandles(id: string): void { + const handles = promptHandles.get(id); + if (handles) { + for (const h of handles) h.cancel(); + promptHandles.delete(id); + } +} + +function waitForSshPromptThenWrite( + id: string, + proc: { + onData: (cb: (data: string) => void) => { dispose: () => void }; + write: (data: string) => void; + }, + data: string, + label: string +): void { + const handles = promptHandles.get(id) ?? []; + promptHandles.set(id, handles); + + const handle = waitForShellPrompt({ + subscribe: (cb) => { + const disposable = proc.onData(cb); + return () => disposable.dispose(); + }, + write: (d) => { + proc.write(d); + promptHandles.delete(id); + }, + data, + onTimeout: () => + log.warn(`${label} SSH shell prompt not detected, writing init commands anyway`, { id }), + }); + handles.push(handle); +} + const providerPtyTimers = new Map(); // Map PTY IDs to provider IDs for multi-agent tracking const ptyProviderMap = new Map(); @@ -55,6 +101,147 @@ type FinishCause = 'process_exit' | 'app_quit' | 'owner_destroyed' | 'manual_kil const ptyDataBuffers = new Map(); const ptyDataTimers = new Map(); const PTY_DATA_FLUSH_MS = 16; +const PTY_ACTIVITY_SAMPLE_CHARS = 8_192; + +const CODEX_BIND_LOOKBACK_MS = 15_000; +const CODEX_BIND_TIMEOUT_MS = 20_000; +const CODEX_BIND_POLL_MS = 250; +const codexBindingQueues = new Map>(); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pruneInvalidCodexResumeTarget( + ptyId: string, + cwd: string, + resume?: boolean +): Promise { + if (!resume) return; + + const exactTarget = getStoredResumeTarget(ptyId, 'codex', cwd); + if (!exactTarget) return; + + const valid = await codexSessionService.threadExistsForCwd(exactTarget, cwd); + if (valid) return; + + clearStoredSession(ptyId); + log.warn('ptyIpc: pruned stale Codex resume target', { ptyId, cwd, exactTarget }); +} + +async function bindCodexThreadForPty(ptyId: string, cwd: string, startedAt: number): Promise { + if (getStoredResumeTarget(ptyId, 'codex', cwd)) { + log.info('ptyIpc: skipping Codex bind because PTY already has a stored target', { + ptyId, + cwd, + }); + return; + } + + const deadline = Date.now() + CODEX_BIND_TIMEOUT_MS; + const since = startedAt - CODEX_BIND_LOOKBACK_MS; + let attempts = 0; + + log.info('ptyIpc: starting Codex thread bind', { + ptyId, + cwd, + startedAt, + since, + timeoutMs: CODEX_BIND_TIMEOUT_MS, + lookbackMs: CODEX_BIND_LOOKBACK_MS, + }); + + const existingThread = await codexSessionService.findLatestThreadForCwd(cwd); + if (existingThread) { + markCodexSessionBound(ptyId, existingThread.id, cwd); + log.info('ptyIpc: bound Codex PTY to existing exact-cwd thread', { + ptyId, + cwd, + threadId: existingThread.id, + updatedAt: existingThread.updatedAt, + }); + return; + } + + while (Date.now() <= deadline) { + attempts += 1; + const thread = await codexSessionService.findLatestRecentThreadForCwd(cwd, since); + if (thread || attempts <= 3 || attempts % 10 === 0) { + log.info('ptyIpc: Codex bind poll result', { + ptyId, + cwd, + attempt: attempts, + candidateThreadId: thread?.id ?? null, + candidateUpdatedAt: thread?.updatedAt ?? null, + }); + } + if (thread) { + markCodexSessionBound(ptyId, thread.id, cwd); + log.info('ptyIpc: bound Codex PTY to thread', { ptyId, cwd, threadId: thread.id }); + return; + } + await sleep(CODEX_BIND_POLL_MS); + } + + const latestThread = await codexSessionService.findLatestThreadForCwd(cwd); + if (latestThread) { + markCodexSessionBound(ptyId, latestThread.id, cwd); + log.info('ptyIpc: bound Codex PTY to latest exact-cwd thread after polling timeout', { + ptyId, + cwd, + attempts, + threadId: latestThread.id, + updatedAt: latestThread.updatedAt, + }); + return; + } + + log.info('ptyIpc: no Codex thread discovered for PTY', { + ptyId, + cwd, + attempts, + latestThreadId: null, + latestThreadUpdatedAt: null, + latestThreadCreatedAt: null, + latestThreadArchived: null, + }); +} + +function scheduleCodexThreadBinding(ptyId: string, cwd: string, startedAt: number): void { + if (getStoredResumeTarget(ptyId, 'codex', cwd)) { + log.info('ptyIpc: not scheduling Codex bind because exact target already exists', { + ptyId, + cwd, + }); + return; + } + + const queueKey = `codex:${cwd}`; + const previous = codexBindingQueues.get(queueKey) ?? Promise.resolve(); + log.info('ptyIpc: scheduling Codex bind', { + ptyId, + cwd, + queueKey, + queuedBehindExistingBind: codexBindingQueues.has(queueKey), + }); + const next = previous + .catch(() => {}) + .then(() => bindCodexThreadForPty(ptyId, cwd, startedAt)) + .catch((error) => { + log.warn('ptyIpc: failed to bind Codex thread', { + ptyId, + cwd, + error: String(error), + }); + }) + .finally(() => { + if (codexBindingQueues.get(queueKey) === next) { + codexBindingQueues.delete(queueKey); + } + }); + + codexBindingQueues.set(queueKey, next); +} // Guard IPC sends to prevent crashes when WebContents is destroyed function safeSendToOwner(id: string, channel: string, payload: unknown): boolean { @@ -74,11 +261,19 @@ function safeSendToOwner(id: string, channel: string, payload: unknown): boolean } } +function sendPtyExitGlobal(id: string): void { + safeSendToOwner(id, 'pty:exit:global', { id }); +} + function flushPtyData(id: string): void { const buf = ptyDataBuffers.get(id); if (!buf) return; ptyDataBuffers.delete(id); safeSendToOwner(id, `pty:data:${id}`, buf); + safeSendToOwner(id, 'pty:activity', { + id, + chunk: buf.length <= PTY_ACTIVITY_SAMPLE_CHARS ? buf : buf.slice(-PTY_ACTIVITY_SAMPLE_CHARS), + }); } function clearPtyData(id: string): void { @@ -90,6 +285,19 @@ function clearPtyData(id: string): void { ptyDataBuffers.delete(id); } +function cleanupPtySession(id: string): void { + // Ensure telemetry timers are cleared even on manual kill + maybeMarkProviderFinish(id, null, undefined, 'manual_kill'); + sendPtyExitGlobal(id); + // Kill associated tmux session if this PTY was tmux-wrapped + if (getPtyTmuxSessionName(id)) { + killTmuxSession(id); + } + killPty(id); + owners.delete(id); + listeners.delete(id); +} + function bufferedSendPtyData(id: string, chunk: string): void { const prev = ptyDataBuffers.get(id) || ''; ptyDataBuffers.set(id, prev + chunk); @@ -153,6 +361,34 @@ async function writeRemoteHookConfig( ]); } +async function writeRemoteOpenCodePlugin( + sshArgs: string[], + sshTarget: string, + ptyId: string +): Promise { + const configDir = OpenCodeHookService.getRemoteConfigDir(ptyId); + const pluginsDir = `${configDir}/plugins`; + const pluginPath = `${pluginsDir}/${OPEN_CODE_PLUGIN_FILE}`; + const pluginSource = OpenCodeHookService.getPluginSource(); + + await execFileAsync('ssh', [ + ...sshArgs, + sshTarget, + `mkdir -p "${pluginsDir}" && printf '%s\\n' ${quoteShellArg(pluginSource)} > "${pluginPath}"`, + ]); + + return configDir; +} + +/** + * Builds a remote init command wrapped in `/bin/sh -c` so it works regardless + * of the remote login shell (fish, csh, etc. can't parse POSIX `${VAR:-…}`). + */ +function buildRemoteInitShellCommand(cwd: string): string { + const posixPayload = `cd ${quoteShellArg(cwd)} && exec "\${SHELL:-/bin/sh}" -il`; + return `/bin/sh -c ${quoteShellArg(posixPayload)}`; +} + function buildRemoteInitKeystrokes(args: { cwd?: string; provider?: { cli: string; cmd: string; installCommand?: string }; @@ -185,13 +421,19 @@ function buildRemoteInitKeystrokes(args: { const shScript = `if command -v ${quoteShellArg(cli)} >/dev/null 2>&1; then if command -v tmux >/dev/null 2>&1; then exec tmux new-session -As ${tmuxName} -- sh -c ${quoteShellArg(providerCmd)}; else printf '%s\\n' 'emdash: tmux not found on remote, running without session persistence'; exec ${providerCmd}; fi; else printf '%s\\n' ${quoteShellArg( msg )}; fi`; - lines.push(`sh -c ${quoteShellArg(shScript)}`); + lines.push(`sh -ilc ${quoteShellArg(shScript)}`); } else { const shScript = `if command -v ${quoteShellArg(cli)} >/dev/null 2>&1; then exec ${providerCmd}; else printf '%s\\n' ${quoteShellArg( msg )}; fi`; - lines.push(`sh -c ${quoteShellArg(shScript)}`); + lines.push(`sh -ilc ${quoteShellArg(shScript)}`); } + } else if (args.tmux) { + // tmux-only (no provider): wrap the shell in a named tmux session for persistence. + // Falls back gracefully if tmux isn't installed on the remote. + const tmuxName = quoteShellArg(args.tmux.sessionName); + const shScript = `if command -v tmux >/dev/null 2>&1; then exec tmux new-session -As ${tmuxName}; else printf '%s\\n' 'emdash: tmux not found on remote, running without session persistence'; fi`; + lines.push(`sh -ilc ${quoteShellArg(shScript)}`); } return lines.length ? `${lines.join('\n')}\n` : ''; @@ -279,6 +521,7 @@ function buildRemoteProviderInvocation(args: { initialPromptFlag: resolvedConfig?.initialPromptFlag ?? fallbackProvider?.initialPromptFlag, useKeystrokeInjection: provider?.useKeystrokeInjection, }); + cliArgs.push(...getProviderRuntimeCliArgs({ providerId, target: 'remote' })); const cmdParts = [...cliCommandParts, ...cliArgs]; const cmd = cmdParts.map(quoteShellArg).join(' '); @@ -340,6 +583,41 @@ async function resolveTmuxEnabled(cwd: string): Promise { return false; } +async function resolveRemoteTmuxEnabled( + ssh: { target: string; args: string[] }, + cwd: string +): Promise { + try { + // Build list of paths to check: cwd first, then project root if cwd is a worktree + const paths = [cwd]; + const marker = '/.emdash/worktrees/'; + const markerIdx = cwd.indexOf(marker); + if (markerIdx > 0) { + paths.push(cwd.slice(0, markerIdx)); + } + + // Read all .emdash.json files in a single SSH exec to avoid multiple round trips + const catParts = paths.map( + (p) => `cat ${quoteShellArg(`${p}/.emdash.json`)} 2>/dev/null || echo '{}'` + ); + const { stdout } = await execFileAsync('ssh', [ + ...ssh.args, + ssh.target, + catParts.join('; echo "---EMDASH_SEP---"; '), + ]); + + const parts = stdout.split('---EMDASH_SEP---'); + for (const part of parts) { + try { + if (JSON.parse(part.trim())?.tmux === true) return true; + } catch {} + } + return false; + } catch { + return false; + } +} + export function registerPtyIpc(): void { // When a direct-spawned CLI exits, spawn a shell so user can continue working setOnDirectCliExit(async (id: string, cwd: string) => { @@ -372,6 +650,7 @@ export function registerPtyIpc(): void { flushPtyData(id); clearPtyData(id); safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); owners.delete(id); listeners.delete(id); removePtyRecord(id); @@ -433,10 +712,14 @@ export function registerPtyIpc(): void { } const ssh = await resolveSshInvocation(remote.connectionId); + + const remoteInitCommand = cwd ? buildRemoteInitShellCommand(cwd) : undefined; + const proc = startSshPty({ id, target: ssh.target, sshArgs: ssh.args, + remoteInitCommand, cols, rows, env, @@ -447,9 +730,11 @@ export function registerPtyIpc(): void { bufferedSendPtyData(id, data); }); proc.onExit(({ exitCode, signal }) => { + cancelPromptHandles(id); flushPtyData(id); clearPtyData(id); safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); owners.delete(id); listeners.delete(id); removePtyRecord(id); @@ -457,13 +742,19 @@ export function registerPtyIpc(): void { listeners.add(id); } - // Resolve tmux config from local project settings - const remoteTmux = cwd ? await resolveTmuxEnabled(cwd) : false; + // Resolve tmux config from remote .emdash.json. + // Workspace-provisioned connections always use tmux for session persistence. + const isWorkspaceConnection = remote.connectionId.startsWith('workspace-'); + const remoteTmux = + isWorkspaceConnection || (cwd ? await resolveRemoteTmuxEnabled(ssh, cwd) : false); const remoteTmuxOpt = remoteTmux ? { sessionName: getTmuxSessionName(id) } : undefined; - const remoteInit = buildRemoteInitKeystrokes({ cwd, tmux: remoteTmuxOpt }); + const remoteInit = buildRemoteInitKeystrokes({ + cwd, + tmux: remoteTmuxOpt, + }); if (remoteInit) { - proc.write(remoteInit); + waitForSshPromptThenWrite(id, proc, remoteInit, 'ptyIpc:start'); } try { @@ -563,6 +854,14 @@ export function registerPtyIpc(): void { const parsedPty = parsePtyId(id); if (parsedPty) maybeAutoTrustForClaude(parsedPty.providerId, cwd); + // Wait for any in-flight setup script to finish before spawning the PTY. + // Setup scripts (e.g. copying .claude/skills into the worktree) must complete + // before the agent initializes, otherwise the copied files won't be picked up + // in the first session. + if (parsedPty?.kind === 'main') { + await taskLifecycleService.awaitSetup(parsedPty.suffix); + } + const shellSetup = cwd ? await resolveShellSetup(cwd) : undefined; const tmux = cwd ? await resolveTmuxEnabled(cwd) : false; @@ -598,6 +897,7 @@ export function registerPtyIpc(): void { return; } safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); maybeMarkProviderFinish( id, exitCode, @@ -712,20 +1012,62 @@ export function registerPtyIpc(): void { ipcMain.on('pty:kill', (_event, args: { id: string }) => { try { - // Ensure telemetry timers are cleared even on manual kill - maybeMarkProviderFinish(args.id, null, undefined, 'manual_kill'); - // Kill associated tmux session if this PTY was tmux-wrapped - if (getPtyTmuxSessionName(args.id)) { - killTmuxSession(args.id); - } - killPty(args.id); - owners.delete(args.id); - listeners.delete(args.id); + cleanupPtySession(args.id); } catch (e) { log.error('pty:kill error', { id: args.id, error: e }); } }); + ipcMain.handle( + 'pty:cleanupSessions', + async ( + _event, + args: { ids: string[]; clearSnapshots?: boolean; waitForSnapshots?: boolean } + ): Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }> => { + const ids = Array.from(new Set((args?.ids || []).filter(Boolean))); + const failedIds: string[] = []; + + for (const id of ids) { + try { + cleanupPtySession(id); + } catch (error) { + failedIds.push(id); + log.error('pty:cleanupSessions kill error', { id, error }); + } + } + + const clearSnapshots = args?.clearSnapshots === true; + const waitForSnapshots = args?.waitForSnapshots === true; + if (clearSnapshots) { + const clearPromise = Promise.allSettled( + ids.map(async (id) => { + try { + await terminalSnapshotService.deleteSnapshot(id); + } catch {} + }) + ); + + if (waitForSnapshots) { + await clearPromise; + } else { + void clearPromise; + } + } + + return { + ok: failedIds.length === 0, + cleaned: ids.length - failedIds.length, + failedIds, + snapshotClearQueued: clearSnapshots, + }; + } + ); + // Kill a tmux session by PTY ID (used during task deletion cleanup) ipcMain.handle('pty:killTmux', async (_event, args: { id: string }) => { try { @@ -867,10 +1209,22 @@ export function registerPtyIpc(): void { const resolvedConfig = resolveProviderCommandConfig(providerId); const mergedEnv = resolvedConfig?.env ? { ...resolvedConfig.env, ...env } : env; + const preProviderCommands: string[] = []; + if (providerId === 'opencode') { + try { + const remoteConfigDir = await writeRemoteOpenCodePlugin(ssh.args, ssh.target, id); + preProviderCommands.push(`export OPENCODE_CONFIG_DIR="${remoteConfigDir}"`); + } catch (err: any) { + log.warn('ptyIpc:startDirect failed to write remote OpenCode plugin', { + id, + error: err?.message || String(err), + }); + } + } + // Set up reverse SSH tunnel for hook events if the local hook // server is running. This lets the remote agent call back to // the local AgentEventService via the tunnel. - const preProviderCommands: string[] = []; const hookPort = agentEventService.getPort(); if (hookPort > 0) { const remotePort = pickReverseTunnelPort(id); @@ -900,10 +1254,13 @@ export function registerPtyIpc(): void { ); } + const remoteInitCommand = cwd ? buildRemoteInitShellCommand(cwd) : undefined; + const proc = startSshPty({ id, target: ssh.target, sshArgs: ssh.args, + remoteInitCommand, cols, rows, env: mergedEnv, @@ -914,9 +1271,11 @@ export function registerPtyIpc(): void { bufferedSendPtyData(id, data); }); proc.onExit(({ exitCode, signal }) => { + cancelPromptHandles(id); flushPtyData(id); clearPtyData(id); safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); maybeMarkProviderFinish(id, exitCode, signal, 'process_exit'); owners.delete(id); listeners.delete(id); @@ -925,8 +1284,11 @@ export function registerPtyIpc(): void { listeners.add(id); } - // Resolve tmux config from local project settings - const remoteTmux = cwd ? await resolveTmuxEnabled(cwd) : false; + // Resolve tmux config from remote .emdash.json. + // Workspace-provisioned connections always use tmux for session persistence. + const isWorkspaceConn = remote.connectionId.startsWith('workspace-'); + const remoteTmux = + isWorkspaceConn || (cwd ? await resolveRemoteTmuxEnabled(ssh, cwd) : false); const tmuxOpt = remoteTmux ? { sessionName: getTmuxSessionName(id) } : undefined; const remoteInit = buildRemoteInitKeystrokes({ @@ -936,7 +1298,7 @@ export function registerPtyIpc(): void { preProviderCommands: preProviderCommands.length ? preProviderCommands : undefined, }); if (remoteInit) { - proc.write(remoteInit); + waitForSshPromptThenWrite(id, proc, remoteInit, 'ptyIpc:startDirect'); } maybeMarkProviderStart(id); @@ -966,10 +1328,24 @@ export function registerPtyIpc(): void { } } + if (providerId === 'codex') { + await pruneInvalidCodexResumeTarget(id, cwd, effectiveResume); + } + maybeAutoTrustForClaude(providerId, cwd); + // Wait for any in-flight setup script to finish before spawning the PTY. + // Setup scripts (e.g. copying .claude/skills into the worktree) must complete + // before the agent initializes, otherwise the copied files won't be picked up + // in the first session. + const parsedDirectPty = parsePtyId(id); + if (parsedDirectPty?.kind === 'main') { + await taskLifecycleService.awaitSetup(parsedDirectPty.suffix); + } + const shellSetup = await resolveShellSetup(cwd); const tmux = await resolveTmuxEnabled(cwd); + const codexBindingStartedAt = providerId === 'codex' ? Date.now() : 0; // Write Claude Code hook config so it calls back to Emdash on events if (providerId === 'claude') { @@ -1051,6 +1427,7 @@ export function registerPtyIpc(): void { return; } safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); // For direct spawn: keep owner (shell respawn reuses it), delete listeners (shell respawn re-adds) // For fallback: clean up owner since no shell respawn happens if (usedFallback) { @@ -1088,6 +1465,10 @@ export function registerPtyIpc(): void { maybeMarkProviderStart(id, providerId as ProviderId); + if (providerId === 'codex') { + scheduleCodexThreadBinding(id, cwd, codexBindingStartedAt); + } + try { const windows = BrowserWindow.getAllWindows(); windows.forEach((w: any) => w.webContents.send('pty:started', { id })); diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 5c1ac34387..1c7cdba1c6 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -2,14 +2,38 @@ import os from 'os'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; +import { StringDecoder } from 'string_decoder'; import type { IPty } from 'node-pty'; import { log } from '../lib/logger'; import { PROVIDERS, type ProviderDefinition } from '@shared/providers/registry'; import { parsePtyId } from '@shared/ptyId'; import { providerStatusCache } from './providerStatusCache'; import { errorTracking } from '../errorTracking'; +import { LOCALE_ENV_VARS, DEFAULT_UTF8_LOCALE, isUtf8Locale } from '../utils/locale'; +import { normalizeClaudeConfigDir } from '../utils/shellEnv'; + +/** + * Suppress EPIPE/EIO errors on a PTY's underlying socket. + * + * On Windows, ConPTY emits EPIPE (not EIO) when the pipe breaks during + * process shutdown. node-pty's built-in error handler only suppresses EIO, + * so EPIPE becomes an uncaught exception. Registering an additional error + * listener bumps the listener count to >= 2, which causes node-pty to + * swallow the error instead of throwing. + */ +function suppressPtyPipeErrors(proc: IPty): void { + if (process.platform !== 'win32') return; + + // IPty doesn't expose .on() in its type, but the underlying Terminal + // class extends EventEmitter and proxies to the socket. + (proc as any).on?.('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE' || err.code === 'EIO') return; + log.warn('ptyManager: unexpected PTY error', { code: err.code, message: err.message }); + }); +} import { getProviderCustomConfig } from '../settings'; import { agentEventService } from './AgentEventService'; +import { OpenCodeHookService } from './OpenCodeHookService'; /** * Environment variables to pass through for agent authentication. @@ -29,11 +53,13 @@ const AGENT_ENV_VARS = [ 'AZURE_OPENAI_API_ENDPOINT', 'AZURE_OPENAI_API_KEY', 'AZURE_OPENAI_KEY', + 'CLAUDE_CONFIG_DIR', 'CODEBUFF_API_KEY', 'COPILOT_CLI_TOKEN', 'CURSOR_API_KEY', 'DASHSCOPE_API_KEY', 'FACTORY_API_KEY', + 'FORGE_API_KEY', 'GEMINI_API_KEY', 'GH_TOKEN', 'GITHUB_TOKEN', @@ -41,14 +67,31 @@ const AGENT_ENV_VARS = [ 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_CLOUD_PROJECT', + 'GLM_API_KEY', + 'GLM_BASE_URL', + 'BROWSERBASE_API_KEY', + 'BROWSERBASE_PROJECT_ID', + 'ELEVENLABS_API_KEY', + 'FAL_KEY', + 'FIRECRAWL_API_KEY', 'HTTP_PROXY', 'HTTPS_PROXY', + 'HONCHO_API_KEY', 'KIMI_API_KEY', + 'KIMI_BASE_URL', 'MISTRAL_API_KEY', + 'MINIMAX_API_KEY', + 'MINIMAX_BASE_URL', + 'MINIMAX_CN_API_KEY', + 'MINIMAX_CN_BASE_URL', 'MOONSHOT_API_KEY', 'NO_PROXY', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', + 'OPENROUTER_API_KEY', + 'TINKER_API_KEY', + 'VOICE_TOOLS_OPENAI_KEY', + 'WANDB_API_KEY', ]; type PtyRecord = { @@ -65,6 +108,104 @@ type PtyRecord = { const ptys = new Map(); const MIN_PTY_COLS = 2; const MIN_PTY_ROWS = 1; +export function getLocaleEnv(sourceEnv: NodeJS.ProcessEnv = process.env): Record { + if (process.platform === 'win32') { + const localeEnv: Record = {}; + for (const key of LOCALE_ENV_VARS) { + const value = sourceEnv[key]; + if (value && isUtf8Locale(value)) { + localeEnv[key] = value; + } + } + return localeEnv; + } + + // On non-Windows, preserve explicit UTF-8 locale choices and only fall back + // to a minimal UTF-8 locale when no effective UTF-8 locale is available. + const localeEnv: Record = {}; + const lang = sourceEnv.LANG; + const lcAll = sourceEnv.LC_ALL; + const lcCtype = sourceEnv.LC_CTYPE; + + if (lcAll && isUtf8Locale(lcAll)) { + localeEnv.LC_ALL = lcAll; + } + if (lang && isUtf8Locale(lang)) { + localeEnv.LANG = lang; + } + if (lcCtype && isUtf8Locale(lcCtype)) { + localeEnv.LC_CTYPE = lcCtype; + } + + if (localeEnv.LC_ALL || localeEnv.LANG || localeEnv.LC_CTYPE) { + return localeEnv; + } + + localeEnv.LANG = DEFAULT_UTF8_LOCALE; + localeEnv.LC_CTYPE = DEFAULT_UTF8_LOCALE; + return localeEnv; +} + +export function mergeEnvWithNormalizedLocale( + ...envs: Array +): Record { + const mergedEnv: NodeJS.ProcessEnv = {}; + + for (const env of envs) { + if (!env) continue; + Object.assign(mergedEnv, env); + } + + const localeEnv = getLocaleEnv(mergedEnv); + for (const key of LOCALE_ENV_VARS) { + delete mergedEnv[key]; + } + + return { + ...mergedEnv, + ...localeEnv, + } as Record; +} + +function applyAgentEventHookEnv(env: Record, ptyId: string): void { + const hookPort = agentEventService.getPort(); + if (hookPort <= 0) return; + + env['EMDASH_HOOK_PORT'] = String(hookPort); + env['EMDASH_PTY_ID'] = ptyId; + env['EMDASH_HOOK_TOKEN'] = agentEventService.getToken(); +} + +function applyOpenCodeRuntimeEnv( + env: Record, + ptyId: string, + providerId?: string +): void { + if (providerId !== 'opencode') return; + + env['OPENCODE_CONFIG_DIR'] = OpenCodeHookService.writeLocalPlugin(ptyId); +} + +function applyProviderSpecificRuntimeEnv( + env: Record, + options: { + ptyId: string; + providerId?: string; + } +): void { + applyOpenCodeRuntimeEnv(env, options.ptyId, options.providerId); +} + +export function applyProviderRuntimeEnv( + env: Record, + options: { + ptyId: string; + providerId?: string; + } +): void { + applyAgentEventHookEnv(env, options.ptyId); + applyProviderSpecificRuntimeEnv(env, options); +} function getWindowsEssentialEnv(): Record { const home = os.homedir(); @@ -128,15 +269,23 @@ export function getTmuxSessionName(ptyId: string): string { return `emdash-${sanitized}`; } +function resolveTmuxPath(): string | null { + return resolveCommandPathCached('tmux'); +} + /** * Kill a tmux session by PTY ID. Fire-and-forget — ignores errors * for non-existent sessions (e.g., tmux not installed or session already dead). */ export function killTmuxSession(ptyId: string): void { const sessionName = getTmuxSessionName(ptyId); + const tmuxPath = resolveTmuxPath(); + if (!tmuxPath) { + return; + } try { const { execFile } = require('child_process'); - execFile('tmux', ['kill-session', '-t', sessionName], { timeout: 5000 }, (err: any) => { + execFile(tmuxPath, ['kill-session', '-t', sessionName], { timeout: 5000 }, (err: any) => { if (!err) { log.info('ptyManager:tmux - killed session', { sessionName }); } @@ -198,16 +347,30 @@ function deterministicUuid(input: string): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } +type SessionStrategy = 'claude-session-id' | 'codex-thread-id'; + // --------------------------------------------------------------------------- -// Persistent session-ID map +// Persistent session map // -// Tracks which PTY IDs have already been started with --session-id so we -// know whether to create a new session or resume an existing one. +// Tracks the exact resume target for PTYs that support strong session restore. // -// First start → no entry → --session-id (create) -// Restart → entry → --resume (resume) +// Claude stores proactively assigned session IDs. +// Codex stores discovered thread IDs after first launch. // --------------------------------------------------------------------------- -type SessionEntry = { uuid: string; cwd: string }; +type SessionEntry = { + cwd: string; + providerId?: string; + uuid?: string; + resumeTarget?: string; + strategy?: SessionStrategy; +}; + +type NormalizedSessionEntry = { + cwd: string; + providerId: string; + target: string; + strategy: SessionStrategy; +}; let _sessionMapPath: string | null = null; let _sessionMap: Record | null = null; @@ -237,26 +400,87 @@ function loadSessionMap(): Record { return _sessionMap!; } +function getNormalizedSessionEntry( + ptyId: string, + entry: SessionEntry | undefined +): NormalizedSessionEntry | null { + if (!entry || typeof entry !== 'object' || typeof entry.cwd !== 'string' || !entry.cwd) { + return null; + } + + const parsed = parsePtyId(ptyId); + const providerId = entry.providerId || parsed?.providerId; + if (!providerId) return null; + + if (typeof entry.resumeTarget === 'string' && entry.resumeTarget.trim()) { + return { + cwd: entry.cwd, + providerId, + target: entry.resumeTarget, + strategy: + entry.strategy === 'claude-session-id' || entry.strategy === 'codex-thread-id' + ? entry.strategy + : providerId === 'codex' + ? 'codex-thread-id' + : 'claude-session-id', + }; + } + + if (typeof entry.uuid === 'string' && entry.uuid.trim()) { + return { + cwd: entry.cwd, + providerId, + target: entry.uuid, + strategy: 'claude-session-id', + }; + } + + return null; +} + /** Check if the session map has entries for other chats of the same provider in the same cwd. */ function hasOtherSameProviderSessions(ptyId: string, providerId: string, cwd: string): boolean { const map = loadSessionMap(); - const prefix = `${providerId}-`; - return Object.entries(map).some( - ([key, entry]) => key.startsWith(prefix) && key !== ptyId && entry.cwd === cwd - ); + return Object.entries(map).some(([key, entry]) => { + if (key === ptyId) return false; + const normalized = getNormalizedSessionEntry(key, entry); + return normalized?.providerId === providerId && normalized.cwd === cwd; + }); } -function markSessionCreated(ptyId: string, uuid: string, cwd: string): void { - const map = loadSessionMap(); - map[ptyId] = { uuid, cwd }; +function persistSessionMap(): void { try { - fs.writeFileSync(sessionMapPath(), JSON.stringify(map)); + fs.writeFileSync(sessionMapPath(), JSON.stringify(loadSessionMap())); } catch (e) { log.warn('ptyManager: failed to persist session map', e); } } -function removeSessionId(ptyId: string): void { +function setSessionEntry(ptyId: string, entry: SessionEntry): void { + const map = loadSessionMap(); + map[ptyId] = entry; + persistSessionMap(); +} + +function markClaudeSessionCreated(ptyId: string, uuid: string, cwd: string): void { + setSessionEntry(ptyId, { + cwd, + providerId: 'claude', + uuid, + strategy: 'claude-session-id', + }); +} + +export function markCodexSessionBound(ptyId: string, threadId: string, cwd: string): void { + setSessionEntry(ptyId, { + cwd, + providerId: 'codex', + resumeTarget: threadId, + strategy: 'codex-thread-id', + }); +} + +export function clearStoredSession(ptyId: string): void { const map = loadSessionMap(); delete map[ptyId]; try { @@ -266,6 +490,17 @@ function removeSessionId(ptyId: string): void { } } +export function getStoredResumeTarget( + ptyId: string, + providerId: string, + cwd?: string +): string | null { + const normalized = getNormalizedSessionEntry(ptyId, loadSessionMap()[ptyId]); + if (!normalized || normalized.providerId !== providerId) return null; + if (cwd && normalized.cwd !== cwd) return null; + return normalized.target; +} + function claudeSessionFileExists(uuid: string, cwd: string): boolean { try { const encoded = cwd.replace(/[:\\/]/g, '-'); @@ -321,11 +556,16 @@ function discoverExistingClaudeSession(cwd: string, excludeUuids: Set): /** Collect all session UUIDs from the map that belong to a given provider in the same cwd, excluding one PTY. */ function getOtherSessionUuids(ptyId: string, providerId: string, cwd: string): Set { const map = loadSessionMap(); - const prefix = `${providerId}-`; const uuids = new Set(); for (const [key, entry] of Object.entries(map)) { - if (key.startsWith(prefix) && key !== ptyId && entry.cwd === cwd) { - uuids.add(entry.uuid); + if (key === ptyId) continue; + const normalized = getNormalizedSessionEntry(key, entry); + if ( + normalized?.providerId === providerId && + normalized.cwd === cwd && + normalized.strategy === 'claude-session-id' + ) { + uuids.add(normalized.target); } } return uuids; @@ -358,20 +598,23 @@ export function applySessionIsolation( const sessionUuid = deterministicUuid(parsed.suffix); const isAdditionalChat = parsed.kind === 'chat'; - const entry = loadSessionMap()[id]; - const knownSession = entry?.uuid; + const knownEntry = getNormalizedSessionEntry(id, loadSessionMap()[id]); + const knownSession = + knownEntry?.providerId === provider.id && knownEntry.strategy === 'claude-session-id' + ? knownEntry.target + : null; if (knownSession) { // For Claude, validate the session still exists on disk before resuming. // Also treat cwd mismatch as stale — the session belongs to a different // project context and Claude would look in the wrong directory. if (provider.id === 'claude') { - const isStale = entry.cwd !== cwd || !claudeSessionFileExists(knownSession, cwd); + const isStale = knownEntry!.cwd !== cwd || !claudeSessionFileExists(knownSession, cwd); if (isStale) { log.warn('ptyManager: stale session detected, creating new session', { ptyId: id, staleUuid: knownSession, }); - removeSessionId(id); + clearStoredSession(id); // Fall through — the decision tree below will create a new session // or the caller will use generic resume flags } else { @@ -386,21 +629,21 @@ export function applySessionIsolation( if (isAdditionalChat) { cliArgs.push(provider.sessionIdFlag, sessionUuid); - markSessionCreated(id, sessionUuid, cwd); + markClaudeSessionCreated(id, sessionUuid, cwd); return true; } - if (hasOtherSameProviderSessions(id, parsed.providerId, cwd)) { + if (isResume && hasOtherSameProviderSessions(id, parsed.providerId, cwd)) { // Main chat transitioning to multi-chat mode. Try to discover its // existing session from Claude's local storage and adopt it. const otherUuids = getOtherSessionUuids(id, parsed.providerId, cwd); const existingSession = discoverExistingClaudeSession(cwd, otherUuids); if (existingSession) { cliArgs.push(provider.sessionIdFlag, existingSession); - markSessionCreated(id, existingSession, cwd); + markClaudeSessionCreated(id, existingSession, cwd); } else { cliArgs.push(provider.sessionIdFlag, sessionUuid); - markSessionCreated(id, sessionUuid, cwd); + markClaudeSessionCreated(id, sessionUuid, cwd); } return true; } @@ -409,13 +652,29 @@ export function applySessionIsolation( // First-time creation — proactively assign a session ID so we can // reliably resume later if more chats of this provider are added. cliArgs.push(provider.sessionIdFlag, sessionUuid); - markSessionCreated(id, sessionUuid, cwd); + markClaudeSessionCreated(id, sessionUuid, cwd); return true; } return false; } +export function getStoredExactResumeArgs(providerId: string, ptyId: string, cwd: string): string[] { + const provider = PROVIDERS.find((item) => item.id === providerId); + if (!provider) return []; + + const entry = getNormalizedSessionEntry(ptyId, loadSessionMap()[ptyId]); + if (!entry || entry.cwd !== cwd || entry.providerId !== provider.id) { + return []; + } + + if (provider.id === 'codex' && entry.strategy === 'codex-thread-id') { + return ['resume', entry.target]; + } + + return []; +} + /** * Parse a shell-style argument string into an array of arguments. * Handles single quotes, double quotes, and escape characters. @@ -516,6 +775,7 @@ type ProviderCliArgsOptions = { resumeFlag?: string; defaultArgs?: string[]; extraArgs?: string[]; + runtimeArgs?: string[]; autoApprove?: boolean; autoApproveFlag?: string; initialPrompt?: string; @@ -523,6 +783,12 @@ type ProviderCliArgsOptions = { useKeystrokeInjection?: boolean; }; +type ProviderRuntimeCliArgsOptions = { + providerId: string; + target?: 'local' | 'remote'; + platform?: NodeJS.Platform; +}; + export function resolveProviderCommandConfig( providerId: string ): ResolvedProviderCommandConfig | null { @@ -587,6 +853,14 @@ export function buildProviderCliArgs(options: ProviderCliArgsOptions): string[] args.push(...parseShellArgs(options.autoApproveFlag)); } + if (options.extraArgs?.length) { + args.push(...options.extraArgs); + } + + if (options.runtimeArgs?.length) { + args.push(...options.runtimeArgs); + } + if ( options.initialPromptFlag !== undefined && !options.useKeystrokeInjection && @@ -598,11 +872,81 @@ export function buildProviderCliArgs(options: ProviderCliArgsOptions): string[] args.push(options.initialPrompt.trim()); } - if (options.extraArgs?.length) { - args.push(...options.extraArgs); + return args; +} + +function makePosixCodexNotifyCommand(): string[] { + const script = + `printf '%s' "$1" | ` + + `curl -sf -X POST ` + + `-H 'Content-Type: application/json' ` + + `-H "X-Emdash-Token: $EMDASH_HOOK_TOKEN" ` + + `-H "X-Emdash-Pty-Id: $EMDASH_PTY_ID" ` + + `-H 'X-Emdash-Event-Type: notification' ` + + `-d @- ` + + `"http://127.0.0.1:$EMDASH_HOOK_PORT/hook" || true`; + + return ['sh', '-lc', script, 'sh']; +} + +function ensureWindowsCodexNotifyScript(): string { + const scriptPath = path.join(os.tmpdir(), 'emdash-codex-notify.ps1'); + const script = [ + 'param([string]$payload)', + 'try {', + ' Invoke-WebRequest -UseBasicParsing -Method POST ' + + "-Uri ('http://127.0.0.1:' + $env:EMDASH_HOOK_PORT + '/hook') " + + '-Headers @{ ' + + "'Content-Type' = 'application/json'; " + + "'X-Emdash-Token' = $env:EMDASH_HOOK_TOKEN; " + + "'X-Emdash-Pty-Id' = $env:EMDASH_PTY_ID; " + + "'X-Emdash-Event-Type' = 'notification' " + + '} -Body $payload | Out-Null', + '} catch {', + ' exit 0', + '}', + '', + ].join('\n'); + + try { + fs.mkdirSync(path.dirname(scriptPath), { recursive: true }); + fs.writeFileSync(scriptPath, script); + } catch (err) { + log.warn('ptyManager: failed to write Codex Windows notify script', { + path: scriptPath, + error: String(err), + }); } - return args; + return scriptPath; +} + +function makeWindowsCodexNotifyCommand(): string[] { + // Use -File so Codex's appended payload argument binds reliably as a script parameter. + return ['powershell.exe', '-NoProfile', '-File', ensureWindowsCodexNotifyScript()]; +} + +function makeCodexNotifyConfigValue(target: 'local' | 'remote', platform: NodeJS.Platform): string { + const notifyCommand = + target === 'remote' || platform !== 'win32' + ? makePosixCodexNotifyCommand() + : makeWindowsCodexNotifyCommand(); + + return `notify=${JSON.stringify(notifyCommand)}`; +} + +export function getProviderRuntimeCliArgs(options: ProviderRuntimeCliArgsOptions): string[] { + const { providerId, target = 'local', platform = process.platform } = options; + + if (providerId !== 'codex') { + return []; + } + + if (agentEventService.getPort() <= 0) { + return []; + } + + return ['-c', makeCodexNotifyConfigValue(target, platform)]; } const resolvedCommandPathCache = new Map(); @@ -755,7 +1099,7 @@ export function startSshPty(options: { HOME: process.env.HOME || os.homedir(), USER: process.env.USER || os.userInfo().username, PATH: process.env.PATH || process.env.Path || '', - ...(process.env.LANG && { LANG: process.env.LANG }), + ...getLocaleEnv(), ...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }), ...getDisplayEnv(), ...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }), @@ -764,9 +1108,16 @@ export function startSshPty(options: { // Pass through agent authentication env vars (same allowlist as direct spawn) for (const key of AGENT_ENV_VARS) { - if (process.env[key]) { - useEnv[key] = process.env[key] as string; + const rawValue = process.env[key]; + if (!rawValue) continue; + if (key === 'CLAUDE_CONFIG_DIR') { + const normalized = normalizeClaudeConfigDir(rawValue); + if (normalized) { + useEnv[key] = normalized; + } + continue; } + useEnv[key] = rawValue as string; } if (env) { @@ -784,13 +1135,29 @@ export function startSshPty(options: { args.push(remoteInitCommand); } - const proc = pty.spawn('ssh', args, { + // On Windows, resolve `ssh` to its full path. node-pty's ConPTY does not + // reliably find bare command names via PATH (unlike Unix forkpty+execvp). + // This mirrors the resolution done in startDirectPty / startPty. + let sshCommand = 'ssh'; + if (process.platform === 'win32') { + const resolved = resolveCommandPathCached('ssh'); + if (!resolved) { + throw new Error( + 'SSH client not found. Install OpenSSH Client via Windows Settings → Apps → Optional Features, or install Git for Windows.' + ); + } + sshCommand = resolved; + } + + const spawnSpec = resolveWindowsPtySpawn(sshCommand, args); + const proc = pty.spawn(spawnSpec.command, spawnSpec.args, { name: 'xterm-256color', cols, rows, cwd: process.env.HOME || os.homedir(), env: useEnv, }); + suppressPtyPipeErrors(proc); ptys.set(id, { id, proc, kind: 'ssh', cols, rows }); return proc; @@ -888,16 +1255,19 @@ export function startDirectPty(options: { const cliArgs: string[] = []; if (provider && resolvedConfig) { + const exactResumeArgs = getStoredExactResumeArgs(provider.id, id, cwd); // Session isolation for multi-chat scenarios. // See applySessionIsolation() for the full decision tree. const usedSessionIsolation = applySessionIsolation(cliArgs, provider, id, cwd, !!resume); + cliArgs.push(...exactResumeArgs); cliArgs.push( ...buildProviderCliArgs({ - resume: !usedSessionIsolation && !!resume, + resume: exactResumeArgs.length === 0 && !usedSessionIsolation && !!resume, resumeFlag: resolvedConfig.resumeFlag, defaultArgs: resolvedConfig.defaultArgs, extraArgs: resolvedConfig.extraArgs, + runtimeArgs: getProviderRuntimeCliArgs({ providerId }), autoApprove, autoApproveFlag: resolvedConfig.autoApproveFlag, initialPrompt, @@ -916,7 +1286,7 @@ export function startDirectPty(options: { USER: process.env.USER || os.userInfo().username, // Include PATH so CLI can find its dependencies PATH: process.env.PATH || process.env.Path || '', - ...(process.env.LANG && { LANG: process.env.LANG }), + ...getLocaleEnv(), ...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }), ...getDisplayEnv(), ...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }), @@ -925,9 +1295,16 @@ export function startDirectPty(options: { // Pass through agent authentication env vars for (const key of AGENT_ENV_VARS) { - if (process.env[key]) { - useEnv[key] = process.env[key]; + const rawValue = process.env[key]; + if (!rawValue) continue; + if (key === 'CLAUDE_CONFIG_DIR') { + const normalized = normalizeClaudeConfigDir(rawValue); + if (normalized) { + useEnv[key] = normalized; + } + continue; } + useEnv[key] = rawValue; } if (resolvedConfig?.env) { @@ -947,13 +1324,7 @@ export function startDirectPty(options: { } } - // Pass agent event hook env vars so CLI hooks can call back to Emdash - const hookPort = agentEventService.getPort(); - if (hookPort > 0) { - useEnv['EMDASH_HOOK_PORT'] = String(hookPort); - useEnv['EMDASH_PTY_ID'] = id; - useEnv['EMDASH_HOOK_TOKEN'] = agentEventService.getToken(); - } + applyProviderRuntimeEnv(useEnv, { ptyId: id, providerId }); // Lazy load native module let pty: typeof import('node-pty'); @@ -971,6 +1342,7 @@ export function startDirectPty(options: { cwd, env: useEnv, }); + suppressPtyPipeErrors(proc); // Store record with cwd for shell respawn after CLI exits ptys.set(id, { id, proc, cwd, isDirectSpawn: true, kind: 'local', cols, rows }); @@ -1027,7 +1399,7 @@ export async function startPty(options: { const defaultShell = getDefaultShell(); let useShell = shell || defaultShell; - const useCwd = cwd || process.cwd() || os.homedir(); + const useCwd = cwd || os.homedir(); // Build a clean environment instead of inheriting process.env wholesale. // @@ -1043,7 +1415,7 @@ export async function startPty(options: { // tools create clean user environments. // // See: https://github.com/generalaction/emdash/issues/485 - const useEnv: Record = { + const useEnv = mergeEnvWithNormalizedLocale({ TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'emdash', @@ -1051,21 +1423,14 @@ export async function startPty(options: { USER: process.env.USER || os.userInfo().username, SHELL: process.env.SHELL || defaultShell, ...(process.platform === 'win32' ? getWindowsEssentialEnv() : {}), - ...(process.env.LANG && { LANG: process.env.LANG }), ...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }), ...(process.env.DISPLAY && { DISPLAY: process.env.DISPLAY }), ...getDisplayEnv(), ...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }), ...(env || {}), - }; + }); - // Pass agent event hook env vars so CLI hooks can call back to Emdash - const hookPort = agentEventService.getPort(); - if (hookPort > 0) { - useEnv['EMDASH_HOOK_PORT'] = String(hookPort); - useEnv['EMDASH_PTY_ID'] = id; - useEnv['EMDASH_HOOK_TOKEN'] = agentEventService.getToken(); - } + applyAgentEventHookEnv(useEnv, id); // On Windows, resolve shell command to full path for node-pty if (process.platform === 'win32' && shell && !shell.includes('\\') && !shell.includes('/')) { @@ -1114,10 +1479,9 @@ export async function startPty(options: { } // Lazy load native module at call time to prevent startup crashes - // eslint-disable-next-line @typescript-eslint/no-var-requires let pty: typeof import('node-pty'); try { - pty = require('node-pty'); + pty = await import('node-pty'); } catch (e: any) { throw new Error(`PTY unavailable: ${e?.message || String(e)}`); } @@ -1135,9 +1499,11 @@ export async function startPty(options: { if (provider) { const resolvedConfig = resolveProviderCommandConfig(provider.id); const resolvedCli = resolvedConfig?.cli || provider.cli || baseLower; + applyProviderSpecificRuntimeEnv(useEnv, { ptyId: id, providerId: provider.id }); // Build the provider command with flags const cliArgs: string[] = []; + const exactResumeArgs = getStoredExactResumeArgs(provider.id, id, useCwd); // Session isolation — see applySessionIsolation() for the full decision tree. const usedSessionIsolation = applySessionIsolation( @@ -1148,12 +1514,14 @@ export async function startPty(options: { !skipResume ); + cliArgs.push(...exactResumeArgs); cliArgs.push( ...buildProviderCliArgs({ - resume: !usedSessionIsolation && !skipResume, + resume: exactResumeArgs.length === 0 && !usedSessionIsolation && !skipResume, resumeFlag: resolvedConfig?.resumeFlag, defaultArgs: resolvedConfig?.defaultArgs, extraArgs: resolvedConfig?.extraArgs, + runtimeArgs: getProviderRuntimeCliArgs({ providerId: provider.id }), autoApprove, autoApproveFlag: resolvedConfig?.autoApproveFlag, initialPrompt, @@ -1232,21 +1600,15 @@ export async function startPty(options: { let spawnArgs = args; if (tmux && process.platform !== 'win32') { - let tmuxAvailable = false; - try { - const { execFileSync } = require('child_process'); - execFileSync('tmux', ['-V'], { timeout: 3000, stdio: 'ignore' }); - tmuxAvailable = true; - } catch { + const tmuxPath = resolveTmuxPath(); + if (!tmuxPath) { log.warn('ptyManager:tmux - tmux not found, falling back to unwrapped spawn', { id }); - } - - if (tmuxAvailable) { + } else { tmuxSessionName = getTmuxSessionName(id); // Build: tmux new-session -As -- - spawnCommand = 'tmux'; + spawnCommand = tmuxPath; spawnArgs = ['new-session', '-As', tmuxSessionName, '--', useShell, ...args]; - log.info('ptyManager:tmux - wrapping in tmux session', { id, tmuxSessionName }); + log.info('ptyManager:tmux - wrapping in tmux session', { id, tmuxSessionName, tmuxPath }); } } @@ -1260,6 +1622,7 @@ export async function startPty(options: { cwd: useCwd, env: useEnv, }); + suppressPtyPipeErrors(proc); } catch (err: any) { // Track initial spawn error const provider = args.find((arg) => PROVIDERS.some((p) => p.cli === arg)); @@ -1278,6 +1641,7 @@ export async function startPty(options: { cwd: useCwd, env: useEnv, }); + suppressPtyPipeErrors(proc); } catch (err2: any) { // Track the fallback spawn error as critical await errorTracking.captureCriticalError(err2, { @@ -1369,3 +1733,263 @@ export function getPtyKind(id: string): 'local' | 'ssh' | undefined { export function getPtyTmuxSessionName(id: string): string | undefined { return ptys.get(id)?.tmuxSessionName; } + +export interface LifecyclePtyHandle { + pid: number | null; + onData: (callback: (data: string) => void) => void; + onExit: (callback: (exitCode: number | null, signal: string | null) => void) => void; + onError: (callback: (error: Error) => void) => void; + kill: (signal?: string) => void; +} + +export function createUtf8StreamForwarder(emitData: (data: string) => void): { + pushStdout: (buf: Buffer) => void; + pushStderr: (buf: Buffer) => void; + flush: () => void; +} { + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + let flushed = false; + + const emitIfPresent = (data: string) => { + if (!data) return; + emitData(data); + }; + + return { + pushStdout: (buf: Buffer) => { + emitIfPresent(stdoutDecoder.write(buf)); + }, + pushStderr: (buf: Buffer) => { + emitIfPresent(stderrDecoder.write(buf)); + }, + flush: () => { + if (flushed) return; + flushed = true; + emitIfPresent(stdoutDecoder.end()); + emitIfPresent(stderrDecoder.end()); + }, + }; +} + +type LifecycleSpawnFallbackChild = { + stdout?: { on: (event: 'data', listener: (buf: Buffer) => void) => void } | null; + stderr?: { on: (event: 'data', listener: (buf: Buffer) => void) => void } | null; + on: (event: 'error' | 'exit' | 'close', listener: (...args: any[]) => void) => void; +}; + +export function attachLifecycleSpawnFallbackHandlers( + child: LifecycleSpawnFallbackChild, + callbacks: { + onData: (data: string) => void; + onExit: (exitCode: number | null, signal: string | null) => void; + onError: (error: Error) => void; + } +): void { + const { onData, onExit, onError } = callbacks; + let didExit = false; + let exitCode: number | null = null; + let exitSignal: string | null = null; + const forwarder = createUtf8StreamForwarder(onData); + + child.stdout?.on('data', (buf: Buffer) => { + forwarder.pushStdout(buf); + }); + child.stderr?.on('data', (buf: Buffer) => { + forwarder.pushStderr(buf); + }); + + child.on('error', (error: Error) => { + forwarder.flush(); + onError(error); + }); + + child.on('exit', (code: number | null, signal: string | null) => { + didExit = true; + exitCode = code; + exitSignal = signal ?? null; + }); + + child.on('close', () => { + // Flush only after stdio closes so buffered UTF-8 bytes can complete. + forwarder.flush(); + if (!didExit) return; + onExit(exitCode, exitSignal); + }); +} + +function startLifecycleSpawnFallback(options: { + id: string; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; +}): LifecyclePtyHandle { + const { spawn } = require('node:child_process') as typeof import('node:child_process'); + const { command, cwd, env } = options; + + const child = spawn(command, { + cwd: cwd || os.homedir(), + shell: true, + detached: true, + env: mergeEnvWithNormalizedLocale(process.env, env), + }); + + const dataCallbacks: Array<(data: string) => void> = []; + const exitCallbacks: Array<(exitCode: number | null, signal: string | null) => void> = []; + const errorCallbacks: Array<(error: Error) => void> = []; + attachLifecycleSpawnFallbackHandlers(child, { + onData: (data) => { + for (const cb of dataCallbacks) cb(data); + }, + onExit: (code, signal) => { + for (const cb of exitCallbacks) cb(code, signal); + }, + onError: (error) => { + for (const cb of errorCallbacks) cb(error); + }, + }); + + return { + pid: child.pid ?? null, + onData: (cb) => dataCallbacks.push(cb), + onExit: (cb) => exitCallbacks.push(cb), + onError: (cb) => errorCallbacks.push(cb), + kill: (signal?: string) => { + const pid = child.pid; + if (!pid) return; + try { + if (process.platform === 'win32') { + const args = ['/PID', String(pid), '/T']; + if (signal === 'SIGKILL') args.push('/F'); + spawn('taskkill', args, { stdio: 'ignore' }).unref(); + } else { + process.kill(-pid, (signal as NodeJS.Signals) || 'SIGTERM'); + } + } catch { + try { + child.kill((signal as NodeJS.Signals) || 'SIGTERM'); + } catch {} + } + }, + }; +} + +export function startLifecyclePty(options: { + id: string; + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; +}): LifecyclePtyHandle { + if (process.env.EMDASH_DISABLE_PTY === '1') { + return startLifecycleSpawnFallback(options); + } + + let pty: typeof import('node-pty'); + try { + pty = require('node-pty'); + } catch { + return startLifecycleSpawnFallback(options); + } + + const { id, command, cwd, env } = options; + const defaultShell = getDefaultShell(); + + const useEnv = mergeEnvWithNormalizedLocale({ + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'emdash', + HOME: process.env.HOME || os.homedir(), + USER: process.env.USER || os.userInfo().username, + SHELL: process.env.SHELL || defaultShell, + ...(process.platform === 'win32' ? getWindowsEssentialEnv() : {}), + ...(process.env.TMPDIR && { TMPDIR: process.env.TMPDIR }), + ...(process.env.DISPLAY && { DISPLAY: process.env.DISPLAY }), + ...getDisplayEnv(), + ...(process.env.SSH_AUTH_SOCK && { SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK }), + ...(env || {}), + }); + + const proc = pty.spawn(defaultShell, ['-ilc', command], { + name: 'xterm-256color', + cols: 120, + rows: 32, + cwd: cwd || os.homedir(), + env: useEnv, + }); + suppressPtyPipeErrors(proc); + + const dataCallbacks: Array<(data: string) => void> = []; + const exitCallbacks: Array<(exitCode: number | null, signal: string | null) => void> = []; + const errorCallbacks: Array<(error: Error) => void> = []; + + proc.onData((data: string) => { + for (const cb of dataCallbacks) { + cb(data); + } + }); + + proc.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => { + ptys.delete(id); + for (const cb of exitCallbacks) { + cb(exitCode, signal != null ? String(signal) : null); + } + }); + + // node-pty generally throws on startup failures instead of emitting an error event, + // but keep the same interface as the child_process fallback. + const emitError = (error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + for (const cb of errorCallbacks) { + cb(normalized); + } + }; + + try { + const maybeProc = proc as IPty & { + on?: (event: string, listener: (error: unknown) => void) => void; + }; + maybeProc.on?.('error', emitError); + } catch {} + + ptys.set(id, { id, proc, cwd, kind: 'local', cols: 120, rows: 32 }); + + return { + pid: typeof proc.pid === 'number' ? proc.pid : null, + onData: (cb) => dataCallbacks.push(cb), + onExit: (cb) => exitCallbacks.push(cb), + onError: (cb) => errorCallbacks.push(cb), + kill: (signal?: string) => { + ptys.delete(id); + try { + proc.kill(signal); + } catch {} + }, + }; +} + +/** + * Return lightweight info about every active PTY for the performance monitor. + * Only reads from the in-memory map — no I/O. + */ +export function getActivePtyInfo(): Array<{ + ptyId: string; + pid: number | null; + kind: 'local' | 'ssh'; + cwd?: string; +}> { + const result: Array<{ + ptyId: string; + pid: number | null; + kind: 'local' | 'ssh'; + cwd?: string; + }> = []; + for (const [id, rec] of ptys) { + result.push({ + ptyId: id, + pid: typeof rec.proc.pid === 'number' ? rec.proc.pid : null, + kind: rec.kind ?? 'local', + cwd: rec.cwd, + }); + } + return result; +} diff --git a/src/main/services/skills/bundled-catalog.json b/src/main/services/skills/bundled-catalog.json index 706e62f602..d7b4ee19ca 100644 --- a/src/main/services/skills/bundled-catalog.json +++ b/src/main/services/skills/bundled-catalog.json @@ -1,6 +1,6 @@ { - "version": 2, - "lastUpdated": "2026-02-07T00:00:00Z", + "version": 3, + "lastUpdated": "2026-03-18T00:00:00Z", "skills": [ { "id": "cloudflare-deploy", @@ -626,6 +626,21 @@ "name": "internal-comms", "description": "Draft internal communications and announcements" } + }, + { + "id": "mmx-cli", + "displayName": "MiniMax CLI", + "description": "Generate text, images, video, speech, and music via MiniMax AI platform", + "source": "skills-sh", + "iconUrl": "https://github.com/MiniMax-AI.png?size=80", + "brandColor": "#171717", + "defaultPrompt": "Use MiniMax to generate content (text, image, video, speech, or music).", + "frontmatter": { + "name": "mmx-cli", + "description": "Generate text, images, video, speech, and music via MiniMax AI platform" + }, + "owner": "MiniMax-AI", + "repo": "cli" } ] } diff --git a/src/main/services/ssh/SshService.ts b/src/main/services/ssh/SshService.ts index 60ccd1f24e..6ae59be2e2 100644 --- a/src/main/services/ssh/SshService.ts +++ b/src/main/services/ssh/SshService.ts @@ -5,9 +5,18 @@ import { Connection, ConnectionPool } from './types'; import { SshCredentialService } from './SshCredentialService'; import { quoteShellArg } from '../../utils/shellEscape'; import { readFile } from 'fs/promises'; +import { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; import { randomUUID } from 'crypto'; import { homedir } from 'os'; -import { resolveIdentityAgent } from '../../utils/sshConfigParser'; +import { spawn, ChildProcess } from 'child_process'; + +const execFileAsync = promisify(execFileCb); +import { + resolveIdentityAgent, + resolveSshConfigHost, + resolveProxyCommand, +} from '../../utils/sshConfigParser'; /** Maximum number of concurrent SSH connections allowed in the pool. */ const MAX_CONNECTIONS = 10; @@ -27,6 +36,7 @@ const POOL_WARNING_THRESHOLD = 0.8; export class SshService extends EventEmitter { private connections: ConnectionPool = {}; private pendingConnections: Map> = new Map(); + private proxyProcesses: Map = new Map(); private credentialService: SshCredentialService; constructor(credentialService?: SshCredentialService) { @@ -95,6 +105,16 @@ export class SshService extends EventEmitter { return new Promise((resolve, reject) => { // Handle connection errors client.on('error', (err: Error) => { + // Clean up any proxy process for this failed connection + const proxyProc = this.proxyProcesses.get(connectionId); + if (proxyProc) { + try { + proxyProc.kill(); + } catch { + /* ignore */ + } + this.proxyProcesses.delete(connectionId); + } reject(err); }); @@ -105,6 +125,15 @@ export class SshService extends EventEmitter { // that was established under the same connectionId. if (this.connections[connectionId]?.client === client) { delete this.connections[connectionId]; + const proxyProc = this.proxyProcesses.get(connectionId); + if (proxyProc) { + try { + proxyProc.kill(); + } catch { + /* ignore */ + } + this.proxyProcesses.delete(connectionId); + } this.emit('disconnected', connectionId); } }); @@ -127,6 +156,12 @@ export class SshService extends EventEmitter { // Build connection config this.buildConnectConfig(connectionId, config) .then((connectConfig) => { + // Track proxy process for cleanup on disconnect + const proxyProc = (connectConfig as any)._proxyProcess as ChildProcess | undefined; + if (proxyProc) { + this.proxyProcesses.set(connectionId, proxyProc); + delete (connectConfig as any)._proxyProcess; + } client.connect(connectConfig); }) .catch((err) => { @@ -142,21 +177,55 @@ export class SshService extends EventEmitter { } /** - * Builds the ssh2 ConnectConfig from our SshConfig + * Builds the ssh2 ConnectConfig from our SshConfig. + * + * ssh2 does not read ~/.ssh/config, so we resolve the host through + * sshConfigParser first. This enables SSH aliases (e.g. + * "my-remote-host") to resolve to their actual HostName, Port, + * User, and IdentityFile as defined in the user's SSH config. */ private async buildConnectConfig( connectionId: string, config: SshConfig ): Promise { + // Resolve SSH config overrides for this host/alias + const sshConfigEntry = await resolveSshConfigHost(config.host); + const connectConfig: ConnectConfig = { - host: config.host, - port: config.port, - username: config.username, + // Use resolved HostName if available, otherwise the original host + host: sshConfigEntry?.hostname ?? config.host, + port: config.port ?? sshConfigEntry?.port ?? 22, + username: config.username ?? sshConfigEntry?.user, readyTimeout: 20000, keepaliveInterval: 60000, keepaliveCountMax: 3, }; + // Check for ProxyCommand in ~/.ssh/config + const proxyCommand = await resolveProxyCommand(config.host, config.port); + if (proxyCommand) { + const { Duplex } = await import('stream'); + const proxyProc = spawn('sh', ['-c', proxyCommand], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + // Create a duplex stream bridging proxy stdout (read) and stdin (write) + const sock = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + return proxyProc.stdin!.write(chunk, encoding, callback); + }, + final(callback) { + proxyProc.stdin!.end(callback); + }, + }); + proxyProc.stdout!.on('data', (data) => sock.push(data)); + proxyProc.stdout!.on('close', () => sock.push(null)); + proxyProc.on('error', (err) => sock.destroy(err)); + + (connectConfig as any).sock = sock; + (connectConfig as any)._proxyProcess = proxyProc; + } + switch (config.authType) { case 'password': { const inlinePassword = (config as any).password as string | undefined; @@ -199,7 +268,9 @@ export class SshService extends EventEmitter { } case 'agent': { - const identityAgent = await resolveIdentityAgent(config.host); + // Prefer the already-resolved config entry to avoid re-parsing ~/.ssh/config + const identityAgent = + sshConfigEntry?.identityAgent ?? (await resolveIdentityAgent(config.host)); const agentSocket = identityAgent || process.env.SSH_AUTH_SOCK; if (!agentSocket) { throw new Error( @@ -258,6 +329,17 @@ export class SshService extends EventEmitter { // Close SSH client connection.client.end(); + // Kill proxy process if one was used + const proxyProc = this.proxyProcesses.get(connectionId); + if (proxyProc) { + try { + proxyProc.kill(); + } catch { + /* ignore */ + } + this.proxyProcesses.delete(connectionId); + } + // Remove from pool delete this.connections[connectionId]; @@ -275,7 +357,9 @@ export class SshService extends EventEmitter { async executeCommand(connectionId: string, command: string, cwd?: string): Promise { const connection = this.connections[connectionId]; if (!connection) { - throw new Error(`Connection ${connectionId} not found`); + // Fallback: execute via SSH CLI binary for connections not in the ssh2 pool + // (e.g. workspace-provisioned connections that were never ssh2-connected). + return this.executeCommandViaCli(connectionId, command, cwd); } // Update last activity @@ -322,6 +406,64 @@ export class SshService extends EventEmitter { }); } + /** + * Fallback: execute a command via the system SSH CLI binary. + * Used for connections that exist in the DB but aren't in the ssh2 pool + * (e.g. workspace-provisioned connections). + */ + private async executeCommandViaCli( + connectionId: string, + command: string, + cwd?: string + ): Promise { + const { getDrizzleClient } = await import('../../db/drizzleClient'); + const { sshConnections } = await import('../../db/schema'); + const { eq } = await import('drizzle-orm'); + + const { db } = await getDrizzleClient(); + const rows = await db + .select({ + host: sshConnections.host, + port: sshConnections.port, + username: sshConnections.username, + privateKeyPath: sshConnections.privateKeyPath, + }) + .from(sshConnections) + .where(eq(sshConnections.id, connectionId)) + .limit(1); + + const row = rows[0]; + if (!row) { + throw new Error(`Connection ${connectionId} not found`); + } + + const sshArgs: string[] = ['-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes']; + if (row.port && row.port !== 22) { + sshArgs.push('-p', String(row.port)); + } + if (row.privateKeyPath) { + sshArgs.push('-i', row.privateKeyPath); + } + + const target = row.username ? `${row.username}@${row.host}` : row.host; + const innerCommand = cwd ? `cd ${quoteShellArg(cwd)} && ${command}` : command; + const fullCommand = `bash -l -c ${quoteShellArg(innerCommand)}`; + + try { + const { stdout, stderr } = await execFileAsync('ssh', [...sshArgs, target, fullCommand], { + maxBuffer: 10 * 1024 * 1024, + timeout: 30000, + }); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 }; + } catch (err: any) { + return { + stdout: (err.stdout ?? '').trim(), + stderr: (err.stderr ?? '').trim(), + exitCode: err.code ?? 1, + }; + } + } + /** * Gets an SFTP session for file operations. * @param connectionId - ID of the active connection diff --git a/src/main/services/worktreeIpc.ts b/src/main/services/worktreeIpc.ts index 92fa09edd1..530fdb4bff 100644 --- a/src/main/services/worktreeIpc.ts +++ b/src/main/services/worktreeIpc.ts @@ -293,6 +293,34 @@ export function registerWorktreeIpc(): void { } ); + // Preflight freshness check — called when create-task UI opens so the + // ls-remote cost is hidden behind user interaction time. + ipcMain.handle( + 'worktree:preflightReserve', + async ( + event, + args: { + projectId: string; + projectPath: string; + } + ) => { + try { + const project = await resolveProjectByIdOrPath({ + projectId: args.projectId, + projectPath: args.projectPath, + }); + if (isRemoteProject(project)) { + return { success: true }; + } + await worktreePoolService.preflightCheck(args.projectId, args.projectPath); + return { success: true }; + } catch (error) { + console.error('Failed to preflight reserve:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + // Check if a reserve is available for a project ipcMain.handle('worktree:hasReserve', async (event, args: { projectId: string }) => { try { diff --git a/src/main/settings.ts b/src/main/settings.ts index b4957b068a..9c1f9b5cd8 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -5,9 +5,20 @@ import { homedir } from 'node:os'; import type { ProviderId } from '@shared/providers/registry'; import { isValidProviderId } from '@shared/providers/registry'; import { isValidOpenInAppId, type OpenInAppId } from '@shared/openInApps'; +import { + isNotificationSoundProfile, + type NotificationSoundProfile, +} from '@shared/notificationSounds'; +import { + DEFAULT_REVIEW_AGENT, + DEFAULT_REVIEW_PROMPT, + type ReviewSettings, +} from '@shared/reviewPreset'; export type DeepPartial = { - [K in keyof T]?: NonNullable extends object ? DeepPartial> : T[K]; + [K in keyof T]?: NonNullable extends object + ? DeepPartial> | Extract + : T[K]; }; export type AppSettingsUpdate = DeepPartial; @@ -18,6 +29,7 @@ const IS_MAC = process.platform === 'darwin'; export interface RepositorySettings { branchPrefix: string; // e.g., 'emdash' pushOnCreate: boolean; + autoCloseLinkedIssuesOnPrCreate: boolean; } export type ShortcutModifier = @@ -34,24 +46,28 @@ export interface ShortcutBinding { modifier: ShortcutModifier; } +export type KeyboardShortcutBinding = ShortcutBinding | null; + export interface KeyboardSettings { - commandPalette?: ShortcutBinding; - settings?: ShortcutBinding; - toggleLeftSidebar?: ShortcutBinding; - toggleRightSidebar?: ShortcutBinding; - toggleTheme?: ShortcutBinding; - toggleKanban?: ShortcutBinding; - toggleEditor?: ShortcutBinding; - closeModal?: ShortcutBinding; - nextProject?: ShortcutBinding; - prevProject?: ShortcutBinding; - newTask?: ShortcutBinding; - nextAgent?: ShortcutBinding; - prevAgent?: ShortcutBinding; + commandPalette?: KeyboardShortcutBinding; + settings?: KeyboardShortcutBinding; + toggleLeftSidebar?: KeyboardShortcutBinding; + toggleRightSidebar?: KeyboardShortcutBinding; + toggleTheme?: KeyboardShortcutBinding; + toggleKanban?: KeyboardShortcutBinding; + toggleEditor?: KeyboardShortcutBinding; + closeModal?: KeyboardShortcutBinding; + nextProject?: KeyboardShortcutBinding; + prevProject?: KeyboardShortcutBinding; + newTask?: KeyboardShortcutBinding; + nextAgent?: KeyboardShortcutBinding; + prevAgent?: KeyboardShortcutBinding; + openInEditor?: KeyboardShortcutBinding; } export interface InterfaceSettings { autoRightSidebarBehavior?: boolean; + showResourceMonitor?: boolean; theme?: 'light' | 'dark' | 'dark-black' | 'system'; taskHoverAction?: 'delete' | 'archive'; } @@ -86,18 +102,17 @@ export interface AppSettings { enabled: boolean; sound: boolean; osNotifications: boolean; + appBadge: boolean; soundFocusMode: 'always' | 'unfocused'; - }; - mcp?: { - context7?: { - enabled: boolean; - installHintsDismissed?: Record; - }; + soundProfile: NotificationSoundProfile; }; defaultProvider?: ProviderId; + review?: ReviewSettings; tasks?: { autoGenerateName: boolean; + autoInferTaskNames: boolean; autoApproveByDefault: boolean; + createWorktreeByDefault: boolean; autoTrustWorktrees: boolean; }; projects?: { @@ -108,10 +123,15 @@ export interface AppSettings { providerConfigs?: ProviderCustomConfigs; terminal?: { fontFamily: string; + fontSize: number; autoCopyOnSelection: boolean; + macOptionIsMeta: boolean; }; defaultOpenInApp?: OpenInAppId; hiddenOpenInApps?: OpenInAppId[]; + changelog?: { + dismissedVersions: string[]; + }; } function getPlatformTaskSwitchDefaults(): { next: ShortcutBinding; prev: ShortcutBinding } { @@ -134,6 +154,7 @@ const DEFAULT_SETTINGS: AppSettings = { repository: { branchPrefix: 'emdash', pushOnCreate: true, + autoCloseLinkedIssuesOnPrCreate: true, }, projectPrep: { autoInstallOnOpenInEditor: true, @@ -146,18 +167,21 @@ const DEFAULT_SETTINGS: AppSettings = { enabled: true, sound: true, osNotifications: true, + appBadge: false, soundFocusMode: 'always', - }, - mcp: { - context7: { - enabled: false, - installHintsDismissed: {}, - }, + soundProfile: 'default', }, defaultProvider: DEFAULT_PROVIDER_ID, + review: { + enabled: false, + agent: DEFAULT_REVIEW_AGENT, + prompt: DEFAULT_REVIEW_PROMPT, + }, tasks: { autoGenerateName: true, + autoInferTaskNames: true, autoApproveByDefault: false, + createWorktreeByDefault: true, autoTrustWorktrees: true, }, projects: { @@ -174,21 +198,28 @@ const DEFAULT_SETTINGS: AppSettings = { nextProject: TASK_SWITCH_DEFAULTS.next, prevProject: TASK_SWITCH_DEFAULTS.prev, newTask: { key: 'n', modifier: 'cmd' }, - nextAgent: { key: 'k', modifier: 'cmd+shift' }, - prevAgent: { key: 'j', modifier: 'cmd+shift' }, + nextAgent: { key: ']', modifier: 'cmd+shift' }, + prevAgent: { key: '[', modifier: 'cmd+shift' }, + openInEditor: { key: 'o', modifier: 'cmd' }, }, interface: { autoRightSidebarBehavior: false, + showResourceMonitor: false, theme: 'system', taskHoverAction: 'delete', }, providerConfigs: {}, terminal: { fontFamily: '', + fontSize: 0, autoCopyOnSelection: false, + macOptionIsMeta: false, }, defaultOpenInApp: 'terminal', hiddenOpenInApps: [], + changelog: { + dismissedVersions: [], + }, }; function getSettingsPath(): string { @@ -261,8 +292,12 @@ function normalizeShortcutModifier(value: unknown, fallback: ShortcutModifier): return aliases[normalized] ?? fallback; } -function isBinding(binding: ShortcutBinding, modifier: ShortcutModifier, key: string): boolean { - return binding.modifier === modifier && binding.key === key; +function isBinding( + binding: KeyboardShortcutBinding | undefined, + modifier: ShortcutModifier, + key: string +): boolean { + return binding?.modifier === modifier && binding?.key === key; } function assertNoKeyboardShortcutConflicts(keyboard?: KeyboardSettings): void { @@ -339,6 +374,7 @@ export function normalizeSettings(input: AppSettings): AppSettings { repository: { branchPrefix: DEFAULT_SETTINGS.repository.branchPrefix, pushOnCreate: DEFAULT_SETTINGS.repository.pushOnCreate, + autoCloseLinkedIssuesOnPrCreate: DEFAULT_SETTINGS.repository.autoCloseLinkedIssuesOnPrCreate, }, projectPrep: { autoInstallOnOpenInEditor: DEFAULT_SETTINGS.projectPrep.autoInstallOnOpenInEditor, @@ -351,13 +387,9 @@ export function normalizeSettings(input: AppSettings): AppSettings { enabled: DEFAULT_SETTINGS.notifications!.enabled, sound: DEFAULT_SETTINGS.notifications!.sound, osNotifications: DEFAULT_SETTINGS.notifications!.osNotifications, + appBadge: DEFAULT_SETTINGS.notifications!.appBadge, soundFocusMode: DEFAULT_SETTINGS.notifications!.soundFocusMode, - }, - mcp: { - context7: { - enabled: DEFAULT_SETTINGS.mcp!.context7!.enabled, - installHintsDismissed: {}, - }, + soundProfile: DEFAULT_SETTINGS.notifications!.soundProfile, }, }; @@ -368,9 +400,14 @@ export function normalizeSettings(input: AppSettings): AppSettings { if (!prefix) prefix = DEFAULT_SETTINGS.repository.branchPrefix; if (prefix.length > 50) prefix = prefix.slice(0, 50); const push = Boolean(repo?.pushOnCreate ?? DEFAULT_SETTINGS.repository.pushOnCreate); + const autoCloseLinkedIssuesOnPrCreate = Boolean( + repo?.autoCloseLinkedIssuesOnPrCreate ?? + DEFAULT_SETTINGS.repository.autoCloseLinkedIssuesOnPrCreate + ); out.repository.branchPrefix = prefix; out.repository.pushOnCreate = push; + out.repository.autoCloseLinkedIssuesOnPrCreate = autoCloseLinkedIssuesOnPrCreate; // Project prep const prep = (input as any)?.projectPrep || {}; out.projectPrep.autoInstallOnOpenInEditor = Boolean( @@ -391,23 +428,14 @@ export function normalizeSettings(input: AppSettings): AppSettings { osNotifications: Boolean( notif?.osNotifications ?? DEFAULT_SETTINGS.notifications!.osNotifications ), + appBadge: Boolean(notif?.appBadge ?? DEFAULT_SETTINGS.notifications!.appBadge), soundFocusMode: rawFocusMode === 'always' || rawFocusMode === 'unfocused' ? rawFocusMode : DEFAULT_SETTINGS.notifications!.soundFocusMode, - }; - - // MCP - const mcp = (input as any)?.mcp || {}; - const c7 = mcp?.context7 || {}; - out.mcp = { - context7: { - enabled: Boolean(c7?.enabled ?? DEFAULT_SETTINGS.mcp!.context7!.enabled), - installHintsDismissed: - c7?.installHintsDismissed && typeof c7.installHintsDismissed === 'object' - ? { ...c7.installHintsDismissed } - : {}, - }, + soundProfile: isNotificationSoundProfile(notif?.soundProfile) + ? notif.soundProfile + : DEFAULT_SETTINGS.notifications!.soundProfile, }; // Default provider @@ -416,13 +444,33 @@ export function normalizeSettings(input: AppSettings): AppSettings { ? defaultProvider : DEFAULT_SETTINGS.defaultProvider!; + const review = (input as any)?.review || {}; + const reviewAgent = isValidProviderId(review?.agent) + ? review.agent + : DEFAULT_SETTINGS.review!.agent; + const reviewPrompt = + typeof review?.prompt === 'string' && review.prompt.trim() + ? review.prompt.trim() + : DEFAULT_SETTINGS.review!.prompt; + out.review = { + enabled: Boolean(review?.enabled ?? DEFAULT_SETTINGS.review!.enabled), + agent: reviewAgent, + prompt: reviewPrompt, + }; + // Tasks const tasks = (input as any)?.tasks || {}; out.tasks = { autoGenerateName: Boolean(tasks?.autoGenerateName ?? DEFAULT_SETTINGS.tasks!.autoGenerateName), + autoInferTaskNames: Boolean( + tasks?.autoInferTaskNames ?? DEFAULT_SETTINGS.tasks!.autoInferTaskNames + ), autoApproveByDefault: Boolean( tasks?.autoApproveByDefault ?? DEFAULT_SETTINGS.tasks!.autoApproveByDefault ), + createWorktreeByDefault: Boolean( + tasks?.createWorktreeByDefault ?? DEFAULT_SETTINGS.tasks!.createWorktreeByDefault + ), autoTrustWorktrees: Boolean( tasks?.autoTrustWorktrees ?? DEFAULT_SETTINGS.tasks!.autoTrustWorktrees ), @@ -446,7 +494,11 @@ export function normalizeSettings(input: AppSettings): AppSettings { // Keyboard const keyboard = (input as any)?.keyboard || {}; - const normalizeBinding = (binding: any, defaultBinding: ShortcutBinding): ShortcutBinding => { + const normalizeBinding = ( + binding: any, + defaultBinding: ShortcutBinding + ): KeyboardShortcutBinding => { + if (binding === null) return null; if (!binding || typeof binding !== 'object') return defaultBinding; const key = normalizeShortcutKey(binding.key) ?? defaultBinding.key; const modifier = normalizeShortcutModifier(binding.modifier, defaultBinding.modifier); @@ -474,6 +526,7 @@ export function normalizeSettings(input: AppSettings): AppSettings { newTask: normalizeBinding(keyboard.newTask, DEFAULT_SETTINGS.keyboard!.newTask!), nextAgent: normalizeBinding(keyboard.nextAgent, DEFAULT_SETTINGS.keyboard!.nextAgent!), prevAgent: normalizeBinding(keyboard.prevAgent, DEFAULT_SETTINGS.keyboard!.prevAgent!), + openInEditor: normalizeBinding(keyboard.openInEditor, DEFAULT_SETTINGS.keyboard!.openInEditor!), }; const platformTaskDefaults = getPlatformTaskSwitchDefaults(); const isLegacyArrowPair = @@ -493,6 +546,9 @@ export function normalizeSettings(input: AppSettings): AppSettings { autoRightSidebarBehavior: Boolean( iface?.autoRightSidebarBehavior ?? DEFAULT_SETTINGS.interface!.autoRightSidebarBehavior ), + showResourceMonitor: Boolean( + iface?.showResourceMonitor ?? DEFAULT_SETTINGS.interface!.showResourceMonitor + ), theme: ['light', 'dark', 'dark-black', 'system'].includes(iface?.theme) ? iface.theme : DEFAULT_SETTINGS.interface!.theme, @@ -539,7 +595,14 @@ export function normalizeSettings(input: AppSettings): AppSettings { const term = (input as any)?.terminal || {}; const fontFamily = String(term?.fontFamily ?? '').trim(); const autoCopyOnSelection = Boolean(term?.autoCopyOnSelection ?? false); - out.terminal = { fontFamily, autoCopyOnSelection }; + const macOptionIsMeta = Boolean(term?.macOptionIsMeta ?? false); + const rawFontSize = term?.fontSize; + let fontSize = 0; + if (typeof rawFontSize === 'number' && Number.isFinite(rawFontSize)) { + const clamped = Math.round(rawFontSize); + fontSize = clamped >= 8 && clamped <= 24 ? clamped : 0; + } + out.terminal = { fontFamily, fontSize, autoCopyOnSelection, macOptionIsMeta }; // Default Open In App const defaultOpenInApp = (input as any)?.defaultOpenInApp; @@ -556,6 +619,20 @@ export function normalizeSettings(input: AppSettings): AppSettings { out.hiddenOpenInApps = []; } + const rawDismissedVersions = (input as any)?.changelog?.dismissedVersions; + out.changelog = { + dismissedVersions: Array.isArray(rawDismissedVersions) + ? [ + ...new Set( + rawDismissedVersions + .filter((value: unknown): value is string => typeof value === 'string') + .map((value) => value.trim().replace(/^v/i, '')) + .filter(Boolean) + ), + ] + : [], + }; + return out; } @@ -589,19 +666,29 @@ export function updateProviderCustomConfig( config: ProviderCustomConfig | undefined ): void { const settings = getAppSettings(); - const currentConfigs = settings.providerConfigs ?? {}; + const currentConfigs = { ...(settings.providerConfigs ?? {}) }; if (config === undefined) { - // Remove the config - const { [providerId]: _, ...rest } = currentConfigs; - updateAppSettings({ providerConfigs: rest }); + delete currentConfigs[providerId]; } else { - // Update/add the config - updateAppSettings({ - providerConfigs: { - ...currentConfigs, - [providerId]: config, - }, - }); + // Strip undefined values so removed fields (e.g. env after + // deleting all env vars) don't linger from a previous save. + const cleaned: ProviderCustomConfig = {}; + for (const [k, v] of Object.entries(config)) { + if (v !== undefined) { + cleaned[k as keyof ProviderCustomConfig] = v; + } + } + if (Object.keys(cleaned).length > 0) { + currentConfigs[providerId] = cleaned; + } else { + delete currentConfigs[providerId]; + } } + + // Write the full providerConfigs map directly to avoid deepMerge + // preserving stale nested keys from the old config. + const next = normalizeSettings({ ...settings, providerConfigs: currentConfigs }); + persistSettings(next); + cached = next; } diff --git a/src/main/telemetry.ts b/src/main/telemetry.ts index 97a0398717..e4d01e0f68 100644 --- a/src/main/telemetry.ts +++ b/src/main/telemetry.ts @@ -72,6 +72,14 @@ type TelemetryEvent = | 'jira_disconnected' | 'jira_issues_searched' | 'jira_issue_selected' + // Plain integration + | 'plain_connected' + | 'plain_disconnected' + | 'plain_threads_searched' + | 'plain_thread_selected' + // Sentry integration + | 'sentry_connected' + | 'sentry_disconnected' // Container & Dev Environment | 'container_connect_clicked' | 'container_connect_success' @@ -121,8 +129,19 @@ type TelemetryEvent = | 'gitlab_disconnected' | 'gitlab_issues_searched' | 'gitlab_issue_selected' + // Forgejo integration + | 'forgejo_connected' + | 'forgejo_disconnected' + | 'forgejo_issues_searched' + | 'forgejo_issue_selected' // Task with issue | 'task_created_with_issue' + // Workspace provider + | 'workspace_provisioning_task_created' + | 'workspace_provisioning_started' + | 'workspace_provisioning_success' + | 'workspace_provisioning_failed' + | 'workspace_provider_config_saved' // Legacy/aggregate events | 'feature_used' | 'error' @@ -482,6 +501,8 @@ export function getTelemetryStatus() { userOptOut: userOptOut === true, hasKeyAndHost: !!apiKey && !!host, onboardingSeen, + posthogKey: apiKey, + posthogHost: host, }; } diff --git a/src/main/utils/__tests__/diffParser.test.ts b/src/main/utils/__tests__/diffParser.test.ts index 31a0a2d5b8..db448bef55 100644 --- a/src/main/utils/__tests__/diffParser.test.ts +++ b/src/main/utils/__tests__/diffParser.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; import { + buildDiffWarnings, + detectLineEndingStyle, parseDiffLines, stripTrailingNewline, MAX_DIFF_CONTENT_BYTES, @@ -19,9 +21,10 @@ describe('parseDiffLines', () => { '+new line\n' + ' world\n'; - const { lines, isBinary } = parseDiffLines(stdout); + const { lines, isBinary, hasHunk } = parseDiffLines(stdout); expect(isBinary).toBe(false); + expect(hasHunk).toBe(true); expect(lines).toEqual([ { left: 'hello', right: 'hello', type: 'context' }, { left: 'old line', type: 'del' }, @@ -78,6 +81,36 @@ describe('parseDiffLines', () => { expect(lines).toEqual([]); }); + it('should parse hunk headers with omitted line counts', () => { + const stdout = + 'diff --git a/f.txt b/f.txt\n' + + 'index 123..456 100644\n' + + '--- a/f.txt\n' + + '+++ b/f.txt\n' + + '@@ -1 +1 @@\n' + + '-old\n' + + '+new\n'; + + const { lines, hasHunk } = parseDiffLines(stdout); + expect(hasHunk).toBe(true); + expect(lines).toEqual([ + { left: 'old', type: 'del' }, + { right: 'new', type: 'add' }, + ]); + }); + + it('should detect git binary patch format', () => { + const stdout = + 'diff --git a/a.bin b/a.bin\n' + + 'index 111..222 100644\n' + + 'GIT binary patch\n' + + 'literal 12\n'; + + const { lines, isBinary } = parseDiffLines(stdout); + expect(isBinary).toBe(true); + expect(lines).toEqual([]); + }); + it('should return empty for empty input', () => { const { lines, isBinary } = parseDiffLines(''); expect(lines).toEqual([]); @@ -92,6 +125,38 @@ describe('parseDiffLines', () => { }); }); +describe('buildDiffWarnings', () => { + it('should detect hidden bidi text', () => { + const warnings = buildDiffWarnings({ + lines: [{ right: `safe\u202Etext`, type: 'add' }], + }); + expect(warnings).toEqual([{ kind: 'hidden-bidi' }]); + }); + + it('should detect line ending changes', () => { + const warnings = buildDiffWarnings({ + originalContent: 'a\r\nb\r\n', + modifiedContent: 'a\nb\n', + }); + expect(warnings).toContainEqual({ + kind: 'line-endings-change', + from: 'crlf', + to: 'lf', + }); + }); +}); + +describe('detectLineEndingStyle', () => { + it('should return none for missing or empty text', () => { + expect(detectLineEndingStyle(undefined)).toBe('none'); + expect(detectLineEndingStyle('')).toBe('none'); + }); + + it('should detect mixed line endings', () => { + expect(detectLineEndingStyle('a\r\nb\nc\r')).toBe('mixed'); + }); +}); + describe('stripTrailingNewline', () => { it('should strip one trailing newline', () => { expect(stripTrailingNewline('hello\n')).toBe('hello'); diff --git a/src/main/utils/__tests__/gitStatusParser.test.ts b/src/main/utils/__tests__/gitStatusParser.test.ts new file mode 100644 index 0000000000..43e85ba743 --- /dev/null +++ b/src/main/utils/__tests__/gitStatusParser.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { combineNumstatValues, parseGitStatusOutput, parseNumstatOutput } from '../gitStatusParser'; + +describe('parseGitStatusOutput', () => { + it('parses porcelain v2 entries with explicit status mapping', () => { + const output = + [ + '1 .D N... 100644 100644 000000 aaaaaaa bbbbbbb deleted.txt', + '2 R. N... 100644 100644 100644 ccccccc ddddddd R100 new-name.ts', + 'old-name.ts', + '? untracked.txt', + ].join('\0') + '\0'; + + const entries = parseGitStatusOutput(output); + expect(entries).toEqual([ + { + path: 'deleted.txt', + statusCode: '.D', + status: 'deleted', + isStaged: false, + }, + { + path: 'new-name.ts', + oldPath: 'old-name.ts', + statusCode: 'R.', + status: 'renamed', + isStaged: true, + }, + { + path: 'untracked.txt', + statusCode: '??', + status: 'added', + isStaged: false, + }, + ]); + }); + + it('falls back to porcelain v1 parsing', () => { + const output = [ + ' D deleted.txt', + 'A added.txt', + 'R old.ts -> new.ts', + '?? untracked.ts', + ].join('\n'); + + const entries = parseGitStatusOutput(output); + expect(entries).toEqual([ + { + path: 'deleted.txt', + statusCode: ' D', + status: 'deleted', + isStaged: false, + }, + { + path: 'added.txt', + statusCode: 'A ', + status: 'added', + isStaged: true, + }, + { + path: 'new.ts', + oldPath: 'old.ts', + statusCode: 'R ', + status: 'renamed', + isStaged: true, + }, + { + path: 'untracked.ts', + statusCode: '??', + status: 'added', + isStaged: false, + }, + ]); + }); + + it('ignores porcelain v1 branch and ignored metadata lines', () => { + const output = ['## main...origin/main', '!! ignored.log', ' M changed.ts'].join('\n'); + + const entries = parseGitStatusOutput(output); + expect(entries).toEqual([ + { + path: 'changed.ts', + statusCode: ' M', + status: 'modified', + isStaged: false, + }, + ]); + }); +}); + +describe('parseNumstatOutput', () => { + it('preserves unknown stats as null and resolves rename paths', () => { + const map = parseNumstatOutput( + ['-\t-\tbinary.dat', '10\t2\tsrc/file.ts', '3\t1\tsrc/{old => new}.ts'].join('\n') + ); + + expect(map.get('binary.dat')).toEqual({ additions: null, deletions: null }); + expect(map.get('src/file.ts')).toEqual({ additions: 10, deletions: 2 }); + expect(map.get('src/new.ts')).toEqual({ additions: 3, deletions: 1 }); + }); +}); + +describe('combineNumstatValues', () => { + it('propagates unknown values', () => { + expect(combineNumstatValues(3, 2)).toBe(5); + expect(combineNumstatValues(undefined, 2)).toBe(2); + expect(combineNumstatValues(3, undefined)).toBe(3); + expect(combineNumstatValues(null, 2)).toBeNull(); + expect(combineNumstatValues(2, null)).toBeNull(); + }); +}); diff --git a/src/main/utils/__tests__/remoteOpenIn.test.ts b/src/main/utils/__tests__/remoteOpenIn.test.ts index 1bc8374371..ce29d3f967 100644 --- a/src/main/utils/__tests__/remoteOpenIn.test.ts +++ b/src/main/utils/__tests__/remoteOpenIn.test.ts @@ -36,11 +36,14 @@ describe('buildRemoteEditorUrl', () => { }); describe('buildGhosttyRemoteExecArgs', () => { - const expectedRemoteShellCommand = - `cd '/home/azureuser/pro/smv/.emdash/worktrees/task one' && ` + + const posixPayload = + "cd '/home/azureuser/pro/smv/.emdash/worktrees/task one' && " + '(if command -v infocmp >/dev/null 2>&1 && [ -n "${TERM:-}" ] && infocmp "${TERM}" >/dev/null 2>&1; then :; else export TERM=xterm-256color; fi) && ' + '(exec "${SHELL:-/bin/bash}" || exec /bin/bash || exec /bin/sh)'; + // quoteShellArg wraps in single quotes and escapes embedded single quotes with '\'' + const expectedRemoteShellCommand = "/bin/sh -c '" + posixPayload.replace(/'/g, "'\\''") + "'"; + it('builds shared remote shell bootstrap command', () => { expect( buildRemoteTerminalShellCommand('/home/azureuser/pro/smv/.emdash/worktrees/task one') @@ -87,7 +90,7 @@ describe('buildGhosttyRemoteExecArgs', () => { '-p', '2202', '-t', - `cd '/tmp/x' && (if command -v infocmp >/dev/null 2>&1 && [ -n "\${TERM:-}" ] && infocmp "\${TERM}" >/dev/null 2>&1; then :; else export TERM=xterm-256color; fi) && (exec "\${SHELL:-/bin/bash}" || exec /bin/bash || exec /bin/sh)`, + buildRemoteTerminalShellCommand('/tmp/x'), ]); }); diff --git a/src/main/utils/__tests__/shellEnv.test.ts b/src/main/utils/__tests__/shellEnv.test.ts index 9df732bdec..50ff2b3e91 100644 --- a/src/main/utils/__tests__/shellEnv.test.ts +++ b/src/main/utils/__tests__/shellEnv.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as os from 'os'; +import * as path from 'path'; import { getShellEnvVar, detectSshAuthSock, initializeShellEnvironment } from '../shellEnv'; // Mock child_process @@ -18,9 +20,28 @@ import { statSync, readdirSync } from 'fs'; const mockedExecSync = vi.mocked(execSync); const mockedStatSync = vi.mocked(statSync); const mockedReaddirSync = vi.mocked(readdirSync); +const shellValue = (value: string) => + `__EMDASH_SHELL_VALUE_START__\n${value}\n__EMDASH_SHELL_VALUE_END__\n`; describe('shellEnv', () => { const originalEnv = process.env; + const fallbackUtf8Locale = + process.platform === 'darwin' + ? 'en_US.UTF-8' + : process.platform === 'win32' + ? undefined + : 'C.UTF-8'; + const shellLookup = (values: Partial>) => (command: string) => { + // Batched locale call: returns values separated by --- + if (command.includes('echo "---"')) { + const keys = [...command.matchAll(/printenv ([A-Z0-9_]+)/g)].map((m) => m[1]!); + return keys.map((k) => values[k] ?? '').join('\n---\n'); + } + // Single var call + const match = command.match(/printenv ([A-Z0-9_]+)/); + if (!match) throw new Error('Command failed'); + return shellValue(values[match[1]!] ?? ''); + }; beforeEach(() => { // Reset process.env @@ -34,7 +55,7 @@ describe('shellEnv', () => { describe('getShellEnvVar', () => { it('should return environment variable from shell', () => { - mockedExecSync.mockReturnValue('/path/to/socket'); + mockedExecSync.mockReturnValue(shellValue('/path/to/socket')); const result = getShellEnvVar('SSH_AUTH_SOCK'); @@ -46,7 +67,7 @@ describe('shellEnv', () => { }); it('should return undefined when variable is empty', () => { - mockedExecSync.mockReturnValue(''); + mockedExecSync.mockReturnValue(shellValue('')); const result = getShellEnvVar('SSH_AUTH_SOCK'); @@ -62,6 +83,16 @@ describe('shellEnv', () => { expect(result).toBeUndefined(); }); + + it('should ignore prompt escape noise around shell output', () => { + mockedExecSync.mockReturnValue( + `\u001b]1337;RemoteHost=test@MacBookPro\u0007${shellValue('/path/to/socket')}\u001b]1337;CurrentDir=/tmp\u0007` + ); + + const result = getShellEnvVar('SSH_AUTH_SOCK'); + + expect(result).toBe('/path/to/socket'); + }); }); describe('detectSshAuthSock', () => { @@ -79,7 +110,12 @@ describe('shellEnv', () => { it('should detect SSH_AUTH_SOCK when not in process.env', () => { delete process.env.SSH_AUTH_SOCK; - mockedExecSync.mockReturnValue('/shell/detected/socket'); + mockedExecSync.mockImplementation((command) => { + if (typeof command === 'string' && command.includes('launchctl getenv SSH_AUTH_SOCK')) { + throw new Error('launchctl failed'); + } + return shellValue('/shell/detected/socket'); + }); const result = detectSshAuthSock(); @@ -142,11 +178,24 @@ describe('shellEnv', () => { describe('initializeShellEnvironment', () => { it('should set process.env.SSH_AUTH_SOCK when socket is detected', () => { delete process.env.SSH_AUTH_SOCK; - mockedExecSync.mockReturnValue('/detected/socket'); + delete process.env.LANG; + delete process.env.LC_CTYPE; + delete process.env.LC_ALL; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + LANG: 'C.UTF-8', + LC_CTYPE: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + }) + ); initializeShellEnvironment(); expect(process.env.SSH_AUTH_SOCK).toBe('/detected/socket'); + expect(process.env.LANG).toBe('C.UTF-8'); + expect(process.env.LC_CTYPE).toBe('C.UTF-8'); + expect(process.env.LC_ALL).toBe('C.UTF-8'); }); it('should fall back to existing SSH_AUTH_SOCK when launchctl fails', () => { @@ -160,5 +209,145 @@ describe('shellEnv', () => { expect(process.env.SSH_AUTH_SOCK).toBe('/existing/socket'); }); + + it('should drop relative CLAUDE_CONFIG_DIR values', () => { + process.env.CLAUDE_CONFIG_DIR = '.claude'; + delete process.env.LANG; + delete process.env.LC_CTYPE; + delete process.env.LC_ALL; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + CLAUDE_CONFIG_DIR: '.claude', + LANG: fallbackUtf8Locale, + LC_CTYPE: fallbackUtf8Locale, + LC_ALL: fallbackUtf8Locale, + }) + ); + + initializeShellEnvironment(); + + expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined(); + }); + + it('should expand shell CLAUDE_CONFIG_DIR values that start with ~/', () => { + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.LANG; + delete process.env.LC_CTYPE; + delete process.env.LC_ALL; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + CLAUDE_CONFIG_DIR: '~/.claude-custom', + LANG: fallbackUtf8Locale, + LC_CTYPE: fallbackUtf8Locale, + LC_ALL: fallbackUtf8Locale, + }) + ); + + initializeShellEnvironment(); + + expect(process.env.CLAUDE_CONFIG_DIR).toBe(path.join(os.homedir(), '.claude-custom')); + }); + + it('should not overwrite explicit locale env values', () => { + process.env.LANG = 'en_US.UTF-8'; + process.env.LC_CTYPE = 'sr_RS.UTF-8'; + process.env.LC_ALL = 'C.UTF-8'; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + LANG: 'ignored.UTF-8', + LC_CTYPE: 'ignored.UTF-8', + LC_ALL: 'ignored.UTF-8', + }) + ); + + initializeShellEnvironment(); + + expect(process.env.LANG).toBe('en_US.UTF-8'); + expect(process.env.LC_CTYPE).toBe('sr_RS.UTF-8'); + expect(process.env.LC_ALL).toBe('C.UTF-8'); + }); + + it('should replace inherited non-UTF-8 locale values with shell UTF-8 values', () => { + process.env.LANG = 'C'; + process.env.LC_CTYPE = 'POSIX'; + process.env.LC_ALL = 'C'; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + LANG: 'en_US.UTF-8', + LC_CTYPE: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + }) + ); + + initializeShellEnvironment(); + + expect(process.env.LANG).toBe('en_US.UTF-8'); + expect(process.env.LC_CTYPE).toBe('en_US.UTF-8'); + expect(process.env.LC_ALL).toBe('en_US.UTF-8'); + }); + + it('should fall back to platform UTF-8 locale when shell exposes no locale values', () => { + delete process.env.LANG; + delete process.env.LC_CTYPE; + delete process.env.LC_ALL; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + LANG: '', + LC_CTYPE: '', + LC_ALL: '', + }) + ); + + initializeShellEnvironment(); + + expect(process.env.LANG).toBe(fallbackUtf8Locale); + expect(process.env.LC_CTYPE).toBe(fallbackUtf8Locale); + expect(process.env.LC_ALL).toBeUndefined(); + }); + + it('should fall back to platform UTF-8 locale when shell exposes only non-UTF-8 locale values', () => { + process.env.LANG = 'C'; + process.env.LC_CTYPE = 'C'; + process.env.LC_ALL = 'C'; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + LANG: 'C', + LC_CTYPE: 'POSIX', + LC_ALL: 'C', + }) + ); + + initializeShellEnvironment(); + + expect(process.env.LANG).toBe(fallbackUtf8Locale); + expect(process.env.LC_CTYPE).toBe(fallbackUtf8Locale); + expect(process.env.LC_ALL).toBeUndefined(); + }); + + it('should drop non-UTF-8 overrides when LANG is already UTF-8', () => { + process.env.LANG = 'en_US.UTF-8'; + process.env.LC_CTYPE = 'C'; + delete process.env.LC_ALL; + mockedExecSync.mockImplementation( + shellLookup({ + SSH_AUTH_SOCK: '/detected/socket', + LANG: '', + LC_CTYPE: 'C', + LC_ALL: '', + }) + ); + + initializeShellEnvironment(); + + expect(process.env.LANG).toBe('en_US.UTF-8'); + expect(process.env.LC_CTYPE).toBeUndefined(); + expect(process.env.LC_ALL).toBeUndefined(); + }); }); }); diff --git a/src/main/utils/__tests__/waitForShellPrompt.test.ts b/src/main/utils/__tests__/waitForShellPrompt.test.ts new file mode 100644 index 0000000000..bbdd05d54f --- /dev/null +++ b/src/main/utils/__tests__/waitForShellPrompt.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { waitForShellPrompt } from '../waitForShellPrompt'; + +describe('waitForShellPrompt', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function createMockPty() { + const listeners: Array<(chunk: string) => void> = []; + return { + subscribe: (cb: (chunk: string) => void) => { + listeners.push(cb); + return () => { + const idx = listeners.indexOf(cb); + if (idx >= 0) listeners.splice(idx, 1); + }; + }, + write: vi.fn(), + emit: (data: string) => { + for (const cb of [...listeners]) cb(data); + }, + listenerCount: () => listeners.length, + }; + } + + it('writes data after detecting a $ prompt', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + expect(pty.write).not.toHaveBeenCalled(); + pty.emit('user@host:~$ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('writes data after detecting a # prompt (root)', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('root@host:~# '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('writes data after detecting a % prompt (zsh)', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('host% '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('writes data after detecting a > prompt', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('PS> '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('writes data after detecting a ❯ prompt (starship)', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('~/projects ❯ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('strips ANSI codes before matching', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('\x1b[32muser@host\x1b[0m:\x1b[34m~\x1b[0m$ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('strips OSC sequences before matching', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('\x1b]0;user@host:~\x07user@host:~$ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('detects a prompt split across chunks', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('user@host:~'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('$ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('detects a fish prompt after greeting output arrives in earlier chunks', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Welcome to fish, the friendly interactive shell\r\n'); + pty.emit('Type help for instructions on how to use fish\r\n'); + pty.emit('user@remote ~/worktrees/1597-fish-prompt'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('> '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('does not match a bare prompt character with no preceding context', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('$ '); + expect(pty.write).not.toHaveBeenCalled(); + }); + + it('does not match MOTD content that lacks prompt characters at end', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Welcome to Ubuntu 22.04 LTS\r\n'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('Last login: Mon Jan 1 00:00:00 2024\r\n'); + expect(pty.write).not.toHaveBeenCalled(); + }); + + it('falls back to timeout when no prompt is detected', () => { + const pty = createMockPty(); + const onTimeout = vi.fn(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + timeoutMs: 5000, + onTimeout, + }); + + pty.emit('Welcome to server\r\n'); + expect(pty.write).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5000); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + expect(onTimeout).toHaveBeenCalled(); + }); + + it('uses default 15s timeout', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + vi.advanceTimersByTime(14999); + expect(pty.write).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('only writes once even if multiple prompt chunks arrive', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('user@host:~$ '); + pty.emit('user@host:~$ '); + expect(pty.write).toHaveBeenCalledTimes(1); + }); + + it('only writes once when prompt detected and then timeout fires', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + timeoutMs: 1000, + }); + + pty.emit('user@host:~$ '); + expect(pty.write).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(pty.write).toHaveBeenCalledTimes(1); + }); + + it('cleans up data listener after prompt detection', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + expect(pty.listenerCount()).toBe(1); + pty.emit('user@host:~$ '); + expect(pty.listenerCount()).toBe(0); + }); + + it('cleans up data listener after timeout', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + timeoutMs: 1000, + }); + + expect(pty.listenerCount()).toBe(1); + vi.advanceTimersByTime(1000); + expect(pty.listenerCount()).toBe(0); + }); + + it('cancel() prevents writing and cleans up', () => { + const pty = createMockPty(); + const handle = waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + timeoutMs: 1000, + }); + + handle.cancel(); + pty.emit('user@host:~$ '); + vi.advanceTimersByTime(1000); + expect(pty.write).not.toHaveBeenCalled(); + expect(pty.listenerCount()).toBe(0); + }); + + it('is a no-op when data is empty', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: '', + }); + + expect(pty.listenerCount()).toBe(0); + pty.emit('user@host:~$ '); + expect(pty.write).not.toHaveBeenCalled(); + }); + + it('does not match download progress ending with %', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Downloading... 100%'); + expect(pty.write).not.toHaveBeenCalled(); + }); + + it('does not match percentage in progress output', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Progress: 50%'); + expect(pty.write).not.toHaveBeenCalled(); + }); + + it('does not match dollar after digit', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Total: 5$'); + expect(pty.write).not.toHaveBeenCalled(); + }); + + it('detects prompt after multiple MOTD chunks', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0)\r\n'); + pty.emit('\r\n'); + pty.emit(' * Documentation: https://help.ubuntu.com\r\n'); + pty.emit(' * Management: https://landscape.canonical.com\r\n'); + pty.emit('\r\n'); + pty.emit('Last login: Mon Mar 6 12:00:00 2026 from 10.0.0.1\r\n'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('user@server:~$ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('detects fish shell prompt (user@host ~>)', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Welcome to fish, the friendly interactive shell\r\n'); + pty.emit('Type `help` for instructions on how to use fish\r\n'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('user@hostname ~> '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('detects fish prompt split across TCP segments (#1597)', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + pty.emit('Welcome to fish, the friendly interactive shell\r\n'); + // Fish may split the prompt across multiple writes over SSH + pty.emit('user@hostname ~'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('> '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('detects prompt when $ arrives alone after accumulated context', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + // Prompt character arrives in its own chunk but there's context in the buffer + pty.emit('user@host:~'); + expect(pty.write).not.toHaveBeenCalled(); + + pty.emit('$ '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('strips Fe escape sequences (cursor save/restore) used by fish', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + // Fish uses \x1b7 (save cursor) and \x1b8 (restore cursor) for right-prompt rendering + pty.emit('user@hostname ~> \x1b7\x1b8'); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('strips OSC sequences with ST terminator', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + // Some terminals/shells use \x1b\\ (ST) instead of \x07 (BEL) to terminate OSC + pty.emit('\x1b]0;fish /home/user\x1b\\user@hostname ~> '); + expect(pty.write).toHaveBeenCalledWith('cd /foo\n'); + }); + + it('does not match bare > without accumulated context', () => { + const pty = createMockPty(); + waitForShellPrompt({ + subscribe: pty.subscribe, + write: pty.write, + data: 'cd /foo\n', + }); + + // Bare > with no preceding non-whitespace context should not match + pty.emit('> '); + expect(pty.write).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/utils/diffParser.ts b/src/main/utils/diffParser.ts index 053aae0bc4..91c0716678 100644 --- a/src/main/utils/diffParser.ts +++ b/src/main/utils/diffParser.ts @@ -1,32 +1,49 @@ +import type { + DiffLine, + DiffLineEnding, + DiffMode, + DiffPayload, + DiffWarning, +} from '../../shared/diff/types'; + /** Maximum bytes for fetching file content in diffs. */ export const MAX_DIFF_CONTENT_BYTES = 512 * 1024; /** Maximum bytes for `git diff` output (larger than content limit due to headers/context). */ export const MAX_DIFF_OUTPUT_BYTES = 10 * 1024 * 1024; -/** Headers emitted by `git diff` that should be skipped when parsing hunks. */ -const DIFF_HEADER_PREFIXES = [ - 'diff ', +const DIFF_METADATA_PREFIXES = [ + 'diff --git ', 'index ', '--- ', '+++ ', - '@@', - 'new file mode', - 'old file mode', - 'deleted file mode', - 'similarity index', - 'rename from', - 'rename to', - 'Binary files', + 'new file mode ', + 'old file mode ', + 'new mode ', + 'old mode ', + 'deleted file mode ', + 'similarity index ', + 'dissimilarity index ', + 'rename from ', + 'rename to ', + 'copy from ', + 'copy to ', + 'Binary files ', + 'GIT binary patch', + 'literal ', + 'delta ', ]; +const NO_NEWLINE_MARKER = '\\ No newline at end of file'; +const HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(?:.*)?$/; +const HIDDEN_BIDI_CHARS_RE = /[\u202A-\u202E\u2066-\u2069]/; -export type DiffLine = { left?: string; right?: string; type: 'context' | 'add' | 'del' }; +export type { DiffLine, DiffLineEnding, DiffMode, DiffWarning }; +export type DiffResult = DiffPayload; -export interface DiffResult { +export interface ParsedDiffLinesResult { lines: DiffLine[]; - isBinary?: boolean; - originalContent?: string; - modifiedContent?: string; + isBinary: boolean; + hasHunk: boolean; } /** Strip exactly one trailing newline, if present. */ @@ -34,20 +51,133 @@ export function stripTrailingNewline(s: string): string { return s.endsWith('\n') ? s.slice(0, -1) : s; } -/** Parse raw `git diff` output into structured diff lines, skipping headers. */ -export function parseDiffLines(stdout: string): { lines: DiffLine[]; isBinary: boolean } { +function isDiffMetadataLine(line: string): boolean { + return DIFF_METADATA_PREFIXES.some((prefix) => line.startsWith(prefix)); +} + +/** Parse raw `git diff` output into structured diff lines, with resilient hunk-state parsing. */ +export function parseDiffLines(stdout: string): ParsedDiffLinesResult { const result: DiffLine[] = []; + let isBinary = false; + let inHunk = false; + let hasHunk = false; + for (const line of stdout.split('\n')) { - if (!line) continue; - if (DIFF_HEADER_PREFIXES.some((p) => line.startsWith(p))) continue; - const prefix = line[0]; - const content = line.slice(1); - if (prefix === '\\') continue; - if (prefix === ' ') result.push({ left: content, right: content, type: 'context' }); - else if (prefix === '-') result.push({ left: content, type: 'del' }); - else if (prefix === '+') result.push({ right: content, type: 'add' }); - else result.push({ left: line, right: line, type: 'context' }); + if (line.startsWith('Binary files ') || line.startsWith('GIT binary patch')) { + isBinary = true; + inHunk = false; + continue; + } + + if (HUNK_HEADER_RE.test(line)) { + inHunk = true; + hasHunk = true; + continue; + } + + if (line.startsWith('diff --git ')) { + inHunk = false; + continue; + } + + if (inHunk) { + if (line === NO_NEWLINE_MARKER) { + continue; + } + + const prefix = line[0]; + const content = line.slice(1); + if (prefix === ' ') { + result.push({ left: content, right: content, type: 'context' }); + continue; + } + if (prefix === '-') { + result.push({ left: content, type: 'del' }); + continue; + } + if (prefix === '+') { + result.push({ right: content, type: 'add' }); + continue; + } + if (prefix === '\\') { + continue; + } + } + + if (!line || isDiffMetadataLine(line)) { + continue; + } + + result.push({ left: line, right: line, type: 'context' }); + } + + if (!isBinary && result.length === 0 && stdout.includes('Binary files')) { + isBinary = true; } - const isBinary = result.length === 0 && stdout.includes('Binary files'); - return { lines: result, isBinary }; + + return { lines: result, isBinary, hasHunk }; +} + +function containsHiddenBidi(text: string | undefined): boolean { + return typeof text === 'string' && HIDDEN_BIDI_CHARS_RE.test(text); +} + +export function detectLineEndingStyle(text: string | undefined): DiffLineEnding { + if (typeof text !== 'string' || text.length === 0) return 'none'; + + let hasLf = false; + let hasCrLf = false; + let hasCr = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + if (char === '\r') { + if (text[i + 1] === '\n') { + hasCrLf = true; + i++; + } else { + hasCr = true; + } + continue; + } + if (char === '\n') { + hasLf = true; + } + } + + const kinds = Number(hasLf) + Number(hasCrLf) + Number(hasCr); + if (kinds > 1) return 'mixed'; + if (hasCrLf) return 'crlf'; + if (hasLf) return 'lf'; + if (hasCr) return 'cr'; + return 'none'; +} + +export function buildDiffWarnings(args: { + originalContent?: string; + modifiedContent?: string; + lines?: DiffLine[]; +}): DiffWarning[] { + const warnings: DiffWarning[] = []; + const { originalContent, modifiedContent, lines = [] } = args; + + let hasHiddenBidi = containsHiddenBidi(originalContent) || containsHiddenBidi(modifiedContent); + if (!hasHiddenBidi) { + hasHiddenBidi = lines.some( + (line) => containsHiddenBidi(line.left) || containsHiddenBidi(line.right) + ); + } + if (hasHiddenBidi) { + warnings.push({ kind: 'hidden-bidi' }); + } + + if (originalContent !== undefined && modifiedContent !== undefined) { + const from = detectLineEndingStyle(originalContent); + const to = detectLineEndingStyle(modifiedContent); + if (from !== to && (from !== 'none' || to !== 'none')) { + warnings.push({ kind: 'line-endings-change', from, to }); + } + } + + return warnings; } diff --git a/src/main/utils/externalLinks.ts b/src/main/utils/externalLinks.ts index 879845d3eb..ab4cf0f282 100644 --- a/src/main/utils/externalLinks.ts +++ b/src/main/utils/externalLinks.ts @@ -9,7 +9,7 @@ export function registerExternalLinkHandlers(win: BrowserWindow, isDev: boolean) const wc = win.webContents; const isInternalAppUrl = (url: string) => { - if (isDev) return url.startsWith('http://localhost:3000'); + if (isDev) return url.startsWith(`http://localhost:${process.env.EMDASH_DEV_PORT || 3000}`); return url.startsWith('file://') || /^http:\/\/(127\.0\.0\.1|localhost):\d+(?:\/|$)/i.test(url); }; diff --git a/src/main/utils/gitStatusParser.ts b/src/main/utils/gitStatusParser.ts new file mode 100644 index 0000000000..b53b67601e --- /dev/null +++ b/src/main/utils/gitStatusParser.ts @@ -0,0 +1,228 @@ +import type { GitFileStatus } from '../../shared/git/types'; + +export interface ParsedGitStatusEntry { + path: string; + oldPath?: string; + statusCode: string; + status: GitFileStatus; + isStaged: boolean; +} + +export interface ParsedNumstat { + additions: number | null; + deletions: number | null; +} + +function normalizeStatusCode(statusCode: string): string { + return statusCode.padEnd(2, '.').slice(0, 2); +} + +function isStagedFromStatusCode(statusCode: string): boolean { + const indexStatus = normalizeStatusCode(statusCode)[0]; + return indexStatus !== '.' && indexStatus !== ' ' && indexStatus !== '?'; +} + +function mapStatusCodeToStatus( + statusCode: string, + rawEntryType?: '1' | '2' | 'u' | '?' +): GitFileStatus { + if (rawEntryType === '?' || statusCode === '??') return 'added'; + + const [indexStatus, worktreeStatus] = normalizeStatusCode(statusCode); + + // Renamed or copied + if ( + rawEntryType === '2' || + indexStatus === 'R' || + worktreeStatus === 'R' || + indexStatus === 'C' || + worktreeStatus === 'C' + ) { + return 'renamed'; + } + + if (indexStatus === 'D' || worktreeStatus === 'D') return 'deleted'; + if (indexStatus === 'A' || worktreeStatus === 'A') return 'added'; + + return 'modified'; +} + +function parsePorcelainV1Line(line: string): ParsedGitStatusEntry | null { + if (line.length < 3) return null; + + const statusCode = line.slice(0, 2); + if (statusCode === '##' || statusCode === '!!') { + // Branch metadata and ignored-path lines are not file changes. + return null; + } + let filePath = line.slice(3); + let oldPath: string | undefined; + + if ((statusCode.includes('R') || statusCode.includes('C')) && filePath.includes(' -> ')) { + const parts = filePath.split(' -> '); + if (parts.length >= 2) { + oldPath = parts[0].trim(); + filePath = parts[parts.length - 1].trim(); + } + } + + return { + path: filePath, + statusCode, + status: mapStatusCodeToStatus(statusCode), + isStaged: isStagedFromStatusCode(statusCode), + ...(oldPath ? { oldPath } : {}), + }; +} + +function parseV1Entries(output: string): ParsedGitStatusEntry[] { + return output + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0) + .map(parsePorcelainV1Line) + .filter((entry): entry is ParsedGitStatusEntry => entry !== null); +} + +function createStatusEntry( + path: string, + statusCode: string, + entryType: '1' | '2' | 'u' | '?', + oldPath?: string +): ParsedGitStatusEntry { + return { + path, + statusCode, + status: mapStatusCodeToStatus(statusCode, entryType), + isStaged: isStagedFromStatusCode(statusCode), + ...(oldPath ? { oldPath } : {}), + }; +} + +function parseV2Entries(tokens: string[]): ParsedGitStatusEntry[] { + const entries: ParsedGitStatusEntry[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const entryType = token[0]; + + // Skip headers and ignored entries + if (entryType === '#' || entryType === '!') continue; + + // Untracked file + if (entryType === '?') { + entries.push(createStatusEntry(token.slice(2), '??', '?')); + continue; + } + + // Regular entry: 1 + if (entryType === '1') { + const fields = token.split(' '); + if (fields.length < 9) continue; + entries.push(createStatusEntry(fields.slice(8).join(' '), fields[1], '1')); + continue; + } + + // Renamed/copied entry: 2 \0 + if (entryType === '2') { + const fields = token.split(' '); + if (fields.length < 10) continue; + const oldPath = tokens[i + 1]; + if (oldPath !== undefined) i += 1; + entries.push(createStatusEntry(fields.slice(9).join(' '), fields[1], '2', oldPath)); + continue; + } + + // Unmerged entry: u

+ if (entryType === 'u') { + const fields = token.split(' '); + if (fields.length < 11) continue; + entries.push(createStatusEntry(fields.slice(10).join(' '), fields[1], 'u')); + } + } + + return entries; +} + +/** + * Parse `git status --porcelain=v2 -z` output. + * + * For fallback support, if the payload does not look like porcelain v2 records + * this parser falls back to porcelain v1 line parsing. + */ +export function parseGitStatusOutput(output: string): ParsedGitStatusEntry[] { + const tokens = output.split('\0').filter((token) => token.length > 0); + + const looksLikePorcelainV2 = tokens.some((token) => /^(?:1|2|u|\?|!|#)\s/.test(token)); + + if (!looksLikePorcelainV2) { + return parseV1Entries(output); + } + + return parseV2Entries(tokens); +} + +function resolveRenamedNumstatPath(filePath: string): string { + if (!filePath.includes(' => ')) return filePath; + + // In-place rename notation: "src/{Old => New}.tsx" + if (filePath.includes('{') && filePath.includes('}')) { + return filePath.replace(/\{[^}]+ => ([^}]+)\}/g, '$1').replace(/\/\//g, '/'); + } + + // Full rename notation: "old.ts => new.ts" + return filePath.split(' => ').pop()?.trim() ?? filePath; +} + +function parseNumstatValue(value: string): number | null { + if (value === '-') return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function mergeNumstatValues(left: number | null, right: number | null): number | null { + if (left === null || right === null) return null; + return left + right; +} + +/** + * Parse `git diff --numstat` output. + * + * Git emits `-` when a value is unknown (for example binary diffs). These + * unknown values are preserved as `null`. + */ +export function parseNumstatOutput(stdout: string): Map { + const map = new Map(); + if (!stdout.trim()) return map; + + const lines = stdout + .split('\n') + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0); + + for (const line of lines) { + const parts = line.split('\t'); + if (parts.length < 3) continue; + + const filePath = resolveRenamedNumstatPath(parts.slice(2).join('\t')); + const current = map.get(filePath); + + const additions = parseNumstatValue(parts[0]); + const deletions = parseNumstatValue(parts[1]); + + map.set(filePath, { + additions: current ? mergeNumstatValues(current.additions, additions) : additions, + deletions: current ? mergeNumstatValues(current.deletions, deletions) : deletions, + }); + } + + return map; +} + +export function combineNumstatValues( + stagedValue: number | null | undefined, + unstagedValue: number | null | undefined +): number | null { + if (stagedValue === null || unstagedValue === null) return null; + return (stagedValue ?? 0) + (unstagedValue ?? 0); +} diff --git a/src/main/utils/locale.ts b/src/main/utils/locale.ts new file mode 100644 index 0000000000..9cd60aac26 --- /dev/null +++ b/src/main/utils/locale.ts @@ -0,0 +1,6 @@ +export const LOCALE_ENV_VARS = ['LANG', 'LC_ALL', 'LC_CTYPE'] as const; +export const DEFAULT_UTF8_LOCALE = 'C.UTF-8'; + +export function isUtf8Locale(value: string | undefined): boolean { + return typeof value === 'string' && /utf-?8/i.test(value); +} diff --git a/src/main/utils/remoteOpenIn.ts b/src/main/utils/remoteOpenIn.ts index 8fa3ddeedd..163045c212 100644 --- a/src/main/utils/remoteOpenIn.ts +++ b/src/main/utils/remoteOpenIn.ts @@ -43,7 +43,9 @@ type GhosttyRemoteExecInput = { * - keep session alive even when SHELL is unset/invalid by chaining shell fallbacks */ export function buildRemoteTerminalShellCommand(targetPath: string): string { - return `cd ${quoteShellArg(targetPath)} && (if command -v infocmp >/dev/null 2>&1 && [ -n "\${TERM:-}" ] && infocmp "\${TERM}" >/dev/null 2>&1; then :; else export TERM=xterm-256color; fi) && (exec "\${SHELL:-/bin/bash}" || exec /bin/bash || exec /bin/sh)`; + // Wrap the POSIX payload in /bin/sh -c so non-POSIX login shells (fish, csh) can execute it. + const posixPayload = `cd ${quoteShellArg(targetPath)} && (if command -v infocmp >/dev/null 2>&1 && [ -n "\${TERM:-}" ] && infocmp "\${TERM}" >/dev/null 2>&1; then :; else export TERM=xterm-256color; fi) && (exec "\${SHELL:-/bin/bash}" || exec /bin/bash || exec /bin/sh)`; + return `/bin/sh -c ${quoteShellArg(posixPayload)}`; } /** diff --git a/src/main/utils/remoteProjectResolver.ts b/src/main/utils/remoteProjectResolver.ts index ec23bd93d2..3f2f184984 100644 --- a/src/main/utils/remoteProjectResolver.ts +++ b/src/main/utils/remoteProjectResolver.ts @@ -1,4 +1,5 @@ import { databaseService, type Project } from '../services/DatabaseService'; +import { workspaceProviderService } from '../services/WorkspaceProviderService'; export type RemoteProject = Project & { sshConnectionId: string; remotePath: string }; @@ -13,6 +14,30 @@ export function isRemoteProject(project: Project | null): project is RemoteProje ); } +export interface RemoteContext { + connectionId: string; + remotePath: string; +} + +export async function resolveRemoteContext( + worktreePath: string, + taskId?: string +): Promise { + // Check workspace instances by taskId first + if (taskId) { + const instance = await workspaceProviderService.getActiveInstance(taskId); + if (instance?.connectionId && instance?.worktreePath) { + return { connectionId: instance.connectionId, remotePath: instance.worktreePath }; + } + } + // Fall back to existing project-based SSH matching + const project = await resolveRemoteProjectForWorktreePath(worktreePath); + if (project) { + return { connectionId: project.sshConnectionId, remotePath: worktreePath }; + } + return null; +} + export async function resolveRemoteProjectForWorktreePath( worktreePath: string ): Promise { diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index bfd8970b3c..1df07e0e50 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -7,6 +7,22 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { stripAnsi } from '@shared/text/stripAnsi'; +import { LOCALE_ENV_VARS, DEFAULT_UTF8_LOCALE, isUtf8Locale } from './locale'; + +const SHELL_VALUE_START = '__EMDASH_SHELL_VALUE_START__'; +const SHELL_VALUE_END = '__EMDASH_SHELL_VALUE_END__'; + +function getFallbackUtf8Locale(): string | undefined { + if (process.platform === 'win32') return undefined; + + // `C.UTF-8` is a good generic fallback on Linux, but can crash AppKit on + // newer macOS builds when native menus initialize locale-dependent text + // direction. Keep macOS on a concrete UTF-8 locale instead. + if (process.platform === 'darwin') return 'en_US.UTF-8'; + + return DEFAULT_UTF8_LOCALE; +} /** * Gets an environment variable from the user's login shell. @@ -24,25 +40,58 @@ export function getShellEnvVar(varName: string): string | undefined { const shell = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); // -i = interactive, -l = login shell (sources .zshrc/.bash_profile) - const result = execSync(`${shell} -ilc 'printenv ${varName} || true'`, { - encoding: 'utf8', - timeout: 5000, - env: { - ...process.env, - // Prevent oh-my-zsh plugins from breaking output - DISABLE_AUTO_UPDATE: 'true', - ZSH_TMUX_AUTOSTART: 'false', - ZSH_TMUX_AUTOSTARTED: 'true', - }, + const result = execSync( + `${shell} -ilc 'printf "${SHELL_VALUE_START}\\n"; printenv ${varName}; printf "${SHELL_VALUE_END}\\n"; exit 0'`, + { + encoding: 'utf8', + timeout: 5000, + env: { + ...process.env, + // Prevent oh-my-zsh plugins from breaking output + DISABLE_AUTO_UPDATE: 'true', + ZSH_TMUX_AUTOSTART: 'false', + ZSH_TMUX_AUTOSTARTED: 'true', + }, + } + ); + + const cleaned = stripAnsi(result, { + stripOscBell: true, + stripOscSt: true, + stripOtherEscapes: true, + stripCarriageReturn: true, }); + const start = cleaned.indexOf(SHELL_VALUE_START); + const end = cleaned.indexOf(SHELL_VALUE_END, start + SHELL_VALUE_START.length); + if (start === -1 || end === -1) { + return undefined; + } - const value = result.trim(); + const value = cleaned.slice(start + SHELL_VALUE_START.length, end).trim(); return value || undefined; } catch { return undefined; } } +export function normalizeClaudeConfigDir(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + + const expanded = + trimmed === '~' + ? os.homedir() + : trimmed.startsWith('~/') + ? path.join(os.homedir(), trimmed.slice(2)) + : trimmed; + + if (!path.isAbsolute(expanded)) { + return undefined; + } + + return path.normalize(expanded); +} + /** * Common SSH agent socket locations to check as fallback */ @@ -179,6 +228,89 @@ export function detectSshAuthSock(): string | undefined { return undefined; } +function getShellLocaleVars(): Partial> { + try { + const shell = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash'); + const printCommands = LOCALE_ENV_VARS.map((v) => `printenv ${v} || echo`).join( + '; echo "---"; ' + ); + const result = execSync(`${shell} -ilc '${printCommands}; exit 0'`, { + encoding: 'utf8', + timeout: 5000, + env: { + ...process.env, + DISABLE_AUTO_UPDATE: 'true', + ZSH_TMUX_AUTOSTART: 'false', + ZSH_TMUX_AUTOSTARTED: 'true', + }, + }); + const parts = result.split('---').map((s) => s.trim()); + const vars: Partial> = {}; + for (let i = 0; i < LOCALE_ENV_VARS.length; i++) { + const value = parts[i]?.trim(); + if (value) vars[LOCALE_ENV_VARS[i]] = value; + } + return vars; + } catch { + return {}; + } +} + +function initializeLocaleEnvironment(): void { + // Check which vars need a shell lookup + const needsLookup: string[] = []; + for (const key of LOCALE_ENV_VARS) { + const currentValue = process.env[key]?.trim(); + if (!currentValue || !isUtf8Locale(currentValue)) { + needsLookup.push(key); + } + } + + // If all locale vars are already UTF-8, nothing to do + if (needsLookup.length === 0) return; + + // Single batched shell call for all missing/non-UTF-8 locale vars + const shellVars = needsLookup.length > 0 ? getShellLocaleVars() : {}; + const missingUtf8Keys: string[] = []; + + for (const key of LOCALE_ENV_VARS) { + const currentValue = process.env[key]?.trim(); + if (currentValue && isUtf8Locale(currentValue)) { + continue; + } + + const shellValue = shellVars[key]; + if (shellValue && isUtf8Locale(shellValue)) { + process.env[key] = shellValue; + continue; + } + + missingUtf8Keys.push(key); + } + + if (process.env.LC_ALL && !isUtf8Locale(process.env.LC_ALL.trim())) { + delete process.env.LC_ALL; + } + + if (process.env.LC_CTYPE && !isUtf8Locale(process.env.LC_CTYPE.trim())) { + delete process.env.LC_CTYPE; + } + + const hasUtf8Lang = isUtf8Locale(process.env.LANG?.trim()); + const hasUtf8LcAll = isUtf8Locale(process.env.LC_ALL?.trim()); + const hasUtf8LcCtype = isUtf8Locale(process.env.LC_CTYPE?.trim()); + + if (hasUtf8LcAll || hasUtf8Lang || hasUtf8LcCtype) return; + + if (missingUtf8Keys.length === 0) return; + + const fallbackLocale = getFallbackUtf8Locale(); + if (!fallbackLocale) return; + + process.env.LANG = fallbackLocale; + process.env.LC_CTYPE = fallbackLocale; +} + /** * Initializes shell environment detection and sets process.env variables. * Should be called early in the main process before app is ready. @@ -191,4 +323,21 @@ export function initializeShellEnvironment(): void { } else { console.log('[shellEnv] SSH_AUTH_SOCK not detected'); } + + // Detect CLAUDE_CONFIG_DIR from login shell when not already in process.env. + // Electron GUI apps on macOS don't inherit the user's shell profile, so the + // var may be missing even if the user has it in ~/.zshrc / ~/.bash_profile. + const existingClaudeConfigDir = normalizeClaudeConfigDir(process.env.CLAUDE_CONFIG_DIR); + if (!existingClaudeConfigDir) { + delete process.env.CLAUDE_CONFIG_DIR; + const claudeConfigDir = normalizeClaudeConfigDir(getShellEnvVar('CLAUDE_CONFIG_DIR')); + if (claudeConfigDir) { + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + console.log('[shellEnv] Detected CLAUDE_CONFIG_DIR'); + } + } else { + process.env.CLAUDE_CONFIG_DIR = existingClaudeConfigDir; + } + + initializeLocaleEnvironment(); } diff --git a/src/main/utils/sshCommandValidation.ts b/src/main/utils/sshCommandValidation.ts new file mode 100644 index 0000000000..19fda4d102 --- /dev/null +++ b/src/main/utils/sshCommandValidation.ts @@ -0,0 +1,38 @@ +const ALLOWED_COMMAND_PREFIXES = [ + 'git ', + 'ls ', + 'pwd', + 'cat ', + 'head ', + 'tail ', + 'wc ', + 'stat ', + 'file ', + 'which ', + 'echo ', + 'test ', + '[ ', +] as const; + +const SHELL_CONTROL_CHARACTER_PATTERN = /[\r\n;&|<>`$]/; + +export function getSshExecuteCommandValidationError(command: string): string | null { + if (SHELL_CONTROL_CHARACTER_PATTERN.test(command)) { + return 'Command contains invalid shell control characters'; + } + + const trimmed = command.trimStart(); + + if (!trimmed) { + return 'Command not allowed'; + } + + const isAllowed = ALLOWED_COMMAND_PREFIXES.some((prefix) => { + if (prefix.endsWith(' ')) { + return trimmed.startsWith(prefix); + } + return trimmed === prefix || trimmed.startsWith(prefix + ' '); + }); + + return isAllowed ? null : 'Command not allowed'; +} diff --git a/src/main/utils/sshConfigParser.ts b/src/main/utils/sshConfigParser.ts index 0b73b387c9..83eb8dd409 100644 --- a/src/main/utils/sshConfigParser.ts +++ b/src/main/utils/sshConfigParser.ts @@ -96,6 +96,13 @@ export async function parseSshConfigFile(): Promise { currentHost.identityAgent = identityAgent; continue; } + + // Match ProxyCommand (supports both "ProxyCommand value" and "ProxyCommand=value") + const proxyCommandMatch = trimmed.match(/^ProxyCommand[\s=]+(.+)$/i); + if (proxyCommandMatch && currentHost) { + currentHost.proxyCommand = proxyCommandMatch[1].trim(); + continue; + } } // Don't forget the last host @@ -106,6 +113,31 @@ export async function parseSshConfigFile(): Promise { return hosts; } +/** + * Resolves SSH config overrides for a given hostname or alias. + * + * Parses ~/.ssh/config and finds a matching host entry by checking + * the Host alias. Returns the resolved fields (hostname, port, user, + * identityFile, identityAgent) so callers can apply them before + * connecting with ssh2 (which does not read ~/.ssh/config natively). + * + * Returns undefined if no matching host is found or if parsing fails. + */ +export async function resolveSshConfigHost( + hostOrAlias: string +): Promise { + try { + const hosts = await parseSshConfigFile(); + return hosts.find( + (h) => + h.host.toLowerCase() === hostOrAlias.toLowerCase() || + h.hostname?.toLowerCase() === hostOrAlias.toLowerCase() + ); + } catch { + return undefined; + } +} + /** * Resolves the IdentityAgent socket path for a given hostname. * @@ -149,3 +181,67 @@ function normalizeIdentityAgent(value: string | undefined): string | undefined { } return value; } + +/** + * Tests whether a hostname matches an SSH Host pattern. + * Supports `*` and `?` wildcards as per OpenSSH. + */ +function hostPatternMatches(pattern: string, hostname: string): boolean { + const regexStr = pattern + .split('') + .map((ch) => (ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'))) + .join(''); + return new RegExp(`^${regexStr}$`, 'i').test(hostname); +} + +/** + * Resolves the ProxyCommand for a given hostname from ~/.ssh/config. + * + * Matches host entries in order (first match wins), supporting glob + * wildcards (`*`, `?`). Replaces `%h` with the hostname and `%p` + * with the port, matching OpenSSH token substitution behavior. + * + * NOTE: This uses a two-pass approach (specific hosts first, then + * wildcards) which diverges from OpenSSH's strict file-order + * first-match-wins semantics. In rare edge cases where a wildcard + * block appears before a specific host block that sets + * `ProxyCommand none`, the results may differ from `ssh`. A fully + * spec-correct implementation would require single-pass in-order + * matching with cumulative keyword application. + */ +export async function resolveProxyCommand( + hostname: string, + port: number = 22 +): Promise { + try { + const hosts = await parseSshConfigFile(); + + // OpenSSH applies settings cumulatively: first matching Host block + // that defines ProxyCommand wins. Check specific match first, then + // walk all entries in order for wildcard/glob matches. + const specificMatch = hosts.find( + (h) => + !h.host.includes('*') && + !h.host.includes('?') && + (h.host.toLowerCase() === hostname.toLowerCase() || + h.hostname?.toLowerCase() === hostname.toLowerCase()) + ); + if (specificMatch?.proxyCommand) { + const cmd = specificMatch.proxyCommand; + if (cmd.toLowerCase() === 'none') return undefined; + return cmd.replace(/%h/g, hostname).replace(/%p/g, String(port)); + } + + // Fall back to wildcard/glob patterns in order + for (const h of hosts) { + if (h.proxyCommand && hostPatternMatches(h.host, hostname)) { + if (h.proxyCommand.toLowerCase() === 'none') return undefined; + return h.proxyCommand.replace(/%h/g, hostname).replace(/%p/g, String(port)); + } + } + + return undefined; + } catch { + return undefined; + } +} diff --git a/src/main/utils/waitForShellPrompt.ts b/src/main/utils/waitForShellPrompt.ts new file mode 100644 index 0000000000..5c7d8f1a2d --- /dev/null +++ b/src/main/utils/waitForShellPrompt.ts @@ -0,0 +1,95 @@ +import { stripAnsi } from '@shared/text/stripAnsi'; + +/** + * Matches common shell prompt endings: $, #, %, >, ❯ preceded by a non-digit, non-space character. + * Tests against a rolling sanitized buffer so prompts split across PTY chunks still match. + */ +const SHELL_PROMPT_RE = /\S.*(?❯]\s*$/; +const PROMPT_END_CHARS = new Set(['#', '$', '%', '>', '❯']); +// Keep enough recent output to match delayed prompts without retaining unbounded PTY history. +const PROMPT_BUFFER_MAX = 1024; + +export interface PromptWaitOptions { + subscribe: (callback: (chunk: string) => void) => () => void; + write: (data: string) => void; + data: string; + timeoutMs?: number; + onTimeout?: () => void; +} + +export interface PromptWaitHandle { + cancel(): void; +} + +/** + * Waits for a shell prompt to appear in PTY output before writing data. + * Accumulates output across chunks so prompts split across TCP segments + * (common with fish shell over SSH) are detected reliably. + * Falls back to writing after a configurable timeout. + */ +export function waitForShellPrompt(options: PromptWaitOptions): PromptWaitHandle { + const { subscribe, write, data, timeoutMs = 15000, onTimeout } = options; + const noop: PromptWaitHandle = { cancel: () => {} }; + if (!data) return noop; + + let done = false; + let promptBuffer = ''; + + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + unsubscribe(); + write(data); + }; + + const cancel = () => { + if (done) return; + done = true; + clearTimeout(timer); + unsubscribe(); + }; + + const unsubscribe = subscribe((chunk: string) => { + if (done) return; + + const clean = stripAnsi(chunk, { + includePrivateCsiParams: true, + stripOtherEscapes: true, + stripOscSt: true, + stripCarriageReturn: true, + }); + if (!clean) return; + + promptBuffer += clean; + + const lastLineBreak = Math.max(promptBuffer.lastIndexOf('\n'), promptBuffer.lastIndexOf('\r')); + if (lastLineBreak >= 0) { + promptBuffer = promptBuffer.slice(lastLineBreak + 1); + } + + if (promptBuffer.length > PROMPT_BUFFER_MAX) { + promptBuffer = promptBuffer.slice(-PROMPT_BUFFER_MAX); + } + + let lastMeaningfulIndex = promptBuffer.length - 1; + while (lastMeaningfulIndex >= 0 && /\s/.test(promptBuffer[lastMeaningfulIndex] ?? '')) { + lastMeaningfulIndex -= 1; + } + if (lastMeaningfulIndex < 0) return; + if (!PROMPT_END_CHARS.has(promptBuffer[lastMeaningfulIndex] ?? '')) { + return; + } + + if (SHELL_PROMPT_RE.test(promptBuffer)) { + finish(); + } + }); + + const timer = setTimeout(() => { + onTimeout?.(); + finish(); + }, timeoutMs); + + return { cancel }; +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f7889a59b8..0c600c9259 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AppSettingsProvider } from './contexts/AppSettingsProvider'; import { AppContextProvider } from './contexts/AppContextProvider'; import { GithubContextProvider } from './contexts/GithubContextProvider'; +import { EmdashAccountProvider } from './contexts/EmdashAccountProvider'; +import { PostHogFeatureFlagProvider } from './contexts/PostHogFeatureFlagProvider'; import { ProjectManagementProvider } from './contexts/ProjectManagementProvider'; import { TaskManagementProvider } from './contexts/TaskManagementContext'; import { ModalProvider } from './contexts/ModalProvider'; @@ -34,17 +36,21 @@ export function App() { - - - - - - {renderContent()} - - - - - + + + + + + + + {renderContent()} + + + + + + + diff --git a/src/renderer/assets/sounds/gilfoyle-bitcoin-alert.mp3 b/src/renderer/assets/sounds/gilfoyle-bitcoin-alert.mp3 new file mode 100644 index 0000000000..b3d190e292 Binary files /dev/null and b/src/renderer/assets/sounds/gilfoyle-bitcoin-alert.mp3 differ diff --git a/src/renderer/components/AgentDropdown.tsx b/src/renderer/components/AgentDropdown.tsx index debddbbea5..27b27ecee6 100644 --- a/src/renderer/components/AgentDropdown.tsx +++ b/src/renderer/components/AgentDropdown.tsx @@ -1,8 +1,11 @@ import React from 'react'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select'; +import { TooltipProvider } from './ui/tooltip'; import { type Agent } from '../types'; import { agentConfig } from '../lib/agentConfig'; import AgentLogo from './AgentLogo'; +import AgentTooltipRow from './AgentTooltipRow'; +import type { UiAgent } from '@/providers/meta'; interface AgentDropdownProps { value: Agent; @@ -21,17 +24,17 @@ export const AgentDropdown: React.FC = ({ }) => { const installedSet = new Set(installedAgents); return ( - onChange(v as Agent)}> + + + + + {Object.entries(agentConfig) + .filter(([key]) => installedSet.has(key)) + .map(([key, config]) => { + const isDisabled = disabledAgents.includes(key); + const content = (
= ({ {isDisabled && (in use)}
- - ); - })} -
- + ); + + return isDisabled ? ( + +
+ {content} +
+
+ ) : ( + + {content} + + ); + })} + + + ); }; diff --git a/src/renderer/components/AgentInfoCard.tsx b/src/renderer/components/AgentInfoCard.tsx index bc2a0ee03e..b1634d9881 100644 --- a/src/renderer/components/AgentInfoCard.tsx +++ b/src/renderer/components/AgentInfoCard.tsx @@ -60,6 +60,11 @@ export const agentInfo: Record = { description: 'OpenCode CLI that interfaces with models for code generation and edits from the shell.', }, + hermes: { + title: 'Hermes Agent', + description: + "Nous Research's autonomous coding agent with persistent memory, skills, and interactive CLI workflows.", + }, charm: { title: 'Charm', description: 'Charm Crush agent CLI providing terminal-first AI assistance for coding tasks.', @@ -124,6 +129,11 @@ export const agentInfo: Record = { description: 'Terminal coding agent with auto-commit, dry-run previews, community skills, and headless CI/CD mode. Supports multiple LLM providers and unrestricted auto-approve for hands-free operation.', }, + forge: { + title: 'Forge', + description: + 'AI coding agent by Antinomy with deep Zsh integration, multi-provider support (OpenRouter, Anthropic, OpenAI), workflows, and interactive or prompt-driven modes.', + }, }; type Props = { diff --git a/src/renderer/components/AgentSelector.tsx b/src/renderer/components/AgentSelector.tsx index 4aee42ef02..afca793439 100644 --- a/src/renderer/components/AgentSelector.tsx +++ b/src/renderer/components/AgentSelector.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; -import { AgentInfoCard } from './AgentInfoCard'; +import AgentTooltipRow from './AgentTooltipRow'; import RoutingInfoCard from './RoutingInfoCard'; import { Workflow } from 'lucide-react'; import { Badge } from './ui/badge'; @@ -58,7 +58,7 @@ export const AgentSelector: React.FC = ({ {Object.entries(agentConfig).map(([key, config]) => ( - +
= ({ {config.name}
-
+ ))} {false && ( @@ -100,33 +100,6 @@ export const AgentSelector: React.FC = ({ ); }; -const TooltipRow: React.FC<{ id: UiAgent; children: React.ReactElement }> = ({ id, children }) => { - const [open, setOpen] = useState(false); - return ( - - - {React.cloneElement(children, { - onMouseEnter: () => setOpen(true), - onMouseLeave: () => setOpen(false), - onPointerEnter: () => setOpen(true), - onPointerLeave: () => setOpen(false), - })} - - setOpen(true)} - onMouseLeave={() => setOpen(false)} - onPointerEnter={() => setOpen(true)} - onPointerLeave={() => setOpen(false)} - > - - - - ); -}; - export default AgentSelector; export const RoutingTooltipRow: React.FC<{ children: React.ReactElement }> = ({ children }) => { diff --git a/src/renderer/components/AgentTooltipRow.tsx b/src/renderer/components/AgentTooltipRow.tsx new file mode 100644 index 0000000000..05d261835c --- /dev/null +++ b/src/renderer/components/AgentTooltipRow.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { AgentInfoCard } from './AgentInfoCard'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; +import type { UiAgent } from '@/providers/meta'; + +type TooltipSide = React.ComponentPropsWithoutRef['side']; +type TooltipAlign = React.ComponentPropsWithoutRef['align']; + +interface AgentTooltipRowProps { + id: UiAgent; + children: React.ReactElement; + open?: boolean; + onOpenChange?: (open: boolean) => void; + side?: TooltipSide; + align?: TooltipAlign; + contentClassName?: string; +} + +export const AgentTooltipRow: React.FC = ({ + id, + children, + open, + onOpenChange, + side = 'right', + align = 'start', + contentClassName = 'border-foreground/20 bg-background p-0 text-foreground', +}) => { + const [internalOpen, setInternalOpen] = useState(false); + const resolvedOpen = open ?? internalOpen; + + const setOpen = (nextOpen: boolean) => { + if (open === undefined) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }; + + return ( + + + {React.cloneElement(children, { + onMouseEnter: (event: React.MouseEvent) => { + children.props.onMouseEnter?.(event); + setOpen(true); + }, + onMouseLeave: (event: React.MouseEvent) => { + children.props.onMouseLeave?.(event); + setOpen(false); + }, + onPointerEnter: (event: React.PointerEvent) => { + children.props.onPointerEnter?.(event); + setOpen(true); + }, + onPointerLeave: (event: React.PointerEvent) => { + children.props.onPointerLeave?.(event); + setOpen(false); + }, + })} + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onPointerEnter={() => setOpen(true)} + onPointerLeave={() => setOpen(false)} + > + + + + ); +}; + +export default AgentTooltipRow; diff --git a/src/renderer/components/AppKeyboardShortcuts.tsx b/src/renderer/components/AppKeyboardShortcuts.tsx index 30f45d540a..995f784ead 100644 --- a/src/renderer/components/AppKeyboardShortcuts.tsx +++ b/src/renderer/components/AppKeyboardShortcuts.tsx @@ -70,6 +70,8 @@ const AppKeyboardShortcuts: React.FC = ({ window.dispatchEvent( new CustomEvent('emdash:switch-agent', { detail: { direction: 'prev' } }) ), + onSelectAgentTab: (tabIndex) => + window.dispatchEvent(new CustomEvent('emdash:select-agent-tab', { detail: { tabIndex } })), onOpenInEditor: handleOpenInEditor, onCloseModal: ( [ diff --git a/src/renderer/components/BranchSelect.tsx b/src/renderer/components/BranchSelect.tsx index 3a71fa1e12..93e8ac398a 100644 --- a/src/renderer/components/BranchSelect.tsx +++ b/src/renderer/components/BranchSelect.tsx @@ -45,8 +45,56 @@ interface BranchSelectProps { const ROW_HEIGHT = 32; const MAX_LIST_HEIGHT = 256; +const MAX_DISPLAYED_OPTIONS = 50; const EMPTY_BRANCH_VALUE = '__branch_select_empty__'; +/** + * Filter and cap options for display. Ensures the selected value is always + * included in the result so Radix can render it in the trigger. + */ +export function filterBranchOptions( + options: BranchOption[], + searchTerm: string, + selectedValue?: string +): { displayed: BranchOption[]; hasMore: boolean; hasKnownSelection: boolean } { + const query = searchTerm.trim().toLowerCase(); + const limit = MAX_DISPLAYED_OPTIONS; + const matches: BranchOption[] = []; + let selectedFound = false; + let totalMatches = 0; + let hasMore = false; + + for (const option of options) { + const isMatch = !query || option.label.toLowerCase().includes(query); + if (isMatch) { + totalMatches++; + if (matches.length < limit) { + matches.push(option); + } else { + hasMore = true; + } + } + if (selectedValue && option.value === selectedValue) { + selectedFound = true; + } + if (hasMore && (selectedFound || !selectedValue)) break; + } + + // Radix Select can only display the trigger text for a value if a matching + // exists in the DOM. If the selected branch falls past the cap, + // prepend it so the trigger doesn't render blank. + if (selectedValue && selectedFound && !matches.some((o) => o.value === selectedValue)) { + const selectedOption = options.find((o) => o.value === selectedValue); + if (selectedOption) matches.unshift(selectedOption); + } + + return { + displayed: matches, + hasMore, + hasKnownSelection: selectedFound, + }; +} + const BranchSelect: React.FC = ({ value, onValueChange, @@ -62,25 +110,30 @@ const BranchSelect: React.FC = ({ const [searchTerm, setSearchTerm] = useState(''); const searchInputRef = useRef(null); + // Freeze options while dropdown is open so Radix doesn't lose the + // selected value when the list changes. Allow the initial load through + // (snapshot has ≤1 item) so branches appear on first open. + const [snapshot, setSnapshot] = useState(options); + useEffect(() => { + if (!open || snapshot.length <= 1) { + setSnapshot(options); + } + }, [open, options]); // eslint-disable-line react-hooks/exhaustive-deps + const stableOptions = open ? snapshot : options; + const navigationKeys = useMemo( () => new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', 'Enter', 'Escape']), [] ); - const filteredOptions = useMemo(() => { - if (!searchTerm.trim()) return options; - const query = searchTerm.trim().toLowerCase(); - return options.filter((option) => option.label.toLowerCase().includes(query)); - }, [options, searchTerm]); - - const displayedOptions = useMemo(() => { - if (!value) return filteredOptions; - const hasSelection = filteredOptions.some((option) => option.value === value); - if (hasSelection) return filteredOptions; - const selectedOption = options.find((option) => option.value === value); - if (!selectedOption) return filteredOptions; - return [selectedOption, ...filteredOptions]; - }, [filteredOptions, options, value]); + const { + displayed: displayedOptions, + hasMore, + hasKnownSelection, + } = useMemo( + () => filterBranchOptions(stableOptions, searchTerm, value), + [stableOptions, searchTerm, value] + ); const estimatedListHeight = Math.min( MAX_LIST_HEIGHT, @@ -106,7 +159,6 @@ const BranchSelect: React.FC = ({ const defaultPlaceholder = isLoading ? 'Loading...' : 'Select branch'; const triggerPlaceholder = placeholder ?? defaultPlaceholder; - const hasKnownSelection = Boolean(value && options.some((option) => option.value === value)); const selectedValue = hasKnownSelection ? (value as string) : EMPTY_BRANCH_VALUE; const triggerClassName = @@ -117,8 +169,13 @@ const BranchSelect: React.FC = ({ return ( { + setWpProvisionCommand(event.target.value); + setError(null); + }} + placeholder="./scripts/create-workspace.sh" + className="font-mono text-xs" + disabled={isSaving} + /> +

+ Script that creates a workspace and outputs SSH connection details as JSON + to stdout. Receives EMDASH_TASK_ID, EMDASH_REPO_URL, EMDASH_BRANCH, + EMDASH_BASE_REF as env vars. +

+ +
+ + { + setWpTerminateCommand(event.target.value); + setError(null); + }} + placeholder="./scripts/destroy-workspace.sh" + className="font-mono text-xs" + disabled={isSaving} + /> +

+ Script that destroys the workspace when the task is deleted. Receives + EMDASH_INSTANCE_ID and EMDASH_TASK_ID as env vars. +

+
+ + + )} +