From db7b59f2beb30045ae36b4e09ce6c73567845622 Mon Sep 17 00:00:00 2001 From: Ngha-Boris Date: Tue, 28 Jul 2026 18:34:04 +0100 Subject: [PATCH 1/2] feat: add ScriptOutput component for generating and sharing setup scripts - Implemented ScriptOutput component to generate Bash scripts, Brewfiles, and Curl URLs based on user-selected OS and packages. - Added functionality for copying scripts to clipboard and downloading them. - Integrated a shareable Curl URL feature with expiration handling. - Included a summary of selected packages and installation estimates. feat: create useClientUseCases hook for accessing client use cases - Introduced useClientUseCases hook to encapsulate client container access, improving code organization and reusability. chore: add Vitest configuration for testing - Set up Vitest configuration with aliasing for module resolution and coverage thresholds. - Configured test environment and included coverage reporting for domain and application layers. --- .github/workflows/ci.yml | 3 + README.md | 16 + docs/adr/0001-hexagonal-architecture.md | 23 + docs/architecture/developer-guide.md | 48 ++ docs/architecture/hexagonal-overview.md | 64 ++ docs/tickets/github-issue-template.md | 149 ++++ .../hexagonal-architecture-refactor.md | 366 ++++++++++ .../hexagonal-architecture-remaining-work.md | 517 +++++++++++++ docs/tickets/hexagonal-refactor-diagram.md | 227 ++++++ docs/tickets/remaining-work-checklist.md | 155 ++++ docs/tickets/remaining-work-github-issue.md | 78 ++ eslint.config.mjs | 1 + package.json | 8 +- pnpm-lock.yaml | 686 ++++++++++++++++++ src/app/api/chat/route.ts | 134 +--- src/app/api/script-share/route.ts | 151 +--- src/app/api/versions/route.ts | 385 ++-------- src/app/globals.css | 74 +- src/application/dto/ai-action.dto.ts | 12 + src/application/dto/chat-message.dto.ts | 10 + src/application/dto/fetch-versions.dto.ts | 7 + src/application/dto/generate-script.dto.ts | 11 + src/application/dto/share-script.dto.ts | 9 + .../ports/incoming/chat-with-ai.port.ts | 5 + .../ports/incoming/generate-script.port.ts | 5 + .../ports/incoming/manage-bucket.port.ts | 8 + .../ports/outgoing/ai-provider.port.ts | 5 + .../ports/outgoing/script-share.port.ts | 10 + .../ports/outgoing/storage.port.ts | 5 + .../use-cases/chat-with-ai.use-case.test.ts | 34 + .../use-cases/chat-with-ai.use-case.ts | 17 + .../use-cases/fetch-versions.use-case.test.ts | 21 + .../use-cases/fetch-versions.use-case.ts | 17 + .../use-cases/generate-brewfile.use-case.ts | 8 + .../generate-script.use-case.test.ts | 43 ++ .../use-cases/generate-script.use-case.ts | 23 + .../get-install-estimates.use-case.ts | 14 + .../get-package-versions.use-case.ts | 60 ++ .../get-packages-for-platform.use-case.ts | 45 ++ .../use-cases/get-preview-command.use-case.ts | 34 + .../use-cases/manage-bucket.use-case.test.ts | 49 ++ .../use-cases/manage-bucket.use-case.ts | 134 ++++ .../parse-ai-action.use-case.test.ts | 36 + .../use-cases/parse-ai-action.use-case.ts | 57 ++ .../use-cases/search-packages.use-case.ts | 70 ++ .../use-cases/share-script.use-case.test.ts | 27 + .../use-cases/share-script.use-case.ts | 26 + src/components/bucket-modal.tsx | 12 +- src/components/chat-window.tsx | 351 +-------- src/components/dependency-panel.tsx | 11 +- src/components/package-manager.tsx | 566 +-------------- src/components/presets-modal.tsx | 12 +- src/components/script-explanation.tsx | 6 +- src/components/script-output.tsx | 455 +----------- src/components/search-bar.tsx | 46 +- src/domain/entities/bucket.test.ts | 42 ++ src/domain/entities/bucket.ts | 37 + src/domain/entities/package.test.ts | 30 + src/domain/entities/package.ts | 118 +++ src/domain/entities/script.test.ts | 36 + src/domain/entities/script.ts | 33 + .../package-repository.interface.ts | 8 + .../version-repository.interface.ts | 4 + src/domain/services/script-generator.test.ts | 126 ++++ .../services}/script-generator.ts | 19 +- src/domain/value-objects/platform.ts | 35 + src/domain/value-objects/shell.ts | 35 + .../value-objects/value-objects.test.ts | 30 + src/domain/value-objects/version.ts | 26 + .../adapters/ai/ai-provider.interface.ts | 1 + .../adapters/ai/groq.adapter.ts | 109 +++ .../adapters/ai/openai.adapter.ts | 10 + .../browser/script-download.adapter.ts | 11 + .../catalog/static-package.repository.test.ts | 16 + .../catalog/static-package.repository.ts | 78 ++ .../adapters/registries/apt.adapter.ts | 1 + .../browser-version.repository.test.ts | 19 + .../registries/browser-version.repository.ts | 13 + .../registries/catalog-package.repository.ts | 3 + .../adapters/registries/homebrew.adapter.ts | 1 + .../registries/http-version.repository.ts | 166 +++++ .../adapters/registries/npm.adapter.ts | 1 + .../adapters/registries/pypi.adapter.ts | 1 + .../adapters/registries/registry.interface.ts | 1 + .../sharing/file-script-share.adapter.ts | 72 ++ .../adapters/storage/local-storage.adapter.ts | 21 + .../adapters/storage/storage.interface.ts | 1 + src/infrastructure/config/client-container.ts | 34 + src/infrastructure/config/di-container.ts | 23 + src/lib/security.ts | 6 +- src/lib/store.ts | 67 +- src/presentation/components/chat-window.tsx | 317 ++++++++ .../components/package-manager.tsx | 496 +++++++++++++ src/presentation/components/script-output.tsx | 456 ++++++++++++ .../hooks/use-client-use-cases.ts | 6 + vitest.config.ts | 27 + 96 files changed, 5808 insertions(+), 2074 deletions(-) create mode 100644 docs/adr/0001-hexagonal-architecture.md create mode 100644 docs/architecture/developer-guide.md create mode 100644 docs/architecture/hexagonal-overview.md create mode 100644 docs/tickets/github-issue-template.md create mode 100644 docs/tickets/hexagonal-architecture-refactor.md create mode 100644 docs/tickets/hexagonal-architecture-remaining-work.md create mode 100644 docs/tickets/hexagonal-refactor-diagram.md create mode 100644 docs/tickets/remaining-work-checklist.md create mode 100644 docs/tickets/remaining-work-github-issue.md create mode 100644 src/application/dto/ai-action.dto.ts create mode 100644 src/application/dto/chat-message.dto.ts create mode 100644 src/application/dto/fetch-versions.dto.ts create mode 100644 src/application/dto/generate-script.dto.ts create mode 100644 src/application/dto/share-script.dto.ts create mode 100644 src/application/ports/incoming/chat-with-ai.port.ts create mode 100644 src/application/ports/incoming/generate-script.port.ts create mode 100644 src/application/ports/incoming/manage-bucket.port.ts create mode 100644 src/application/ports/outgoing/ai-provider.port.ts create mode 100644 src/application/ports/outgoing/script-share.port.ts create mode 100644 src/application/ports/outgoing/storage.port.ts create mode 100644 src/application/use-cases/chat-with-ai.use-case.test.ts create mode 100644 src/application/use-cases/chat-with-ai.use-case.ts create mode 100644 src/application/use-cases/fetch-versions.use-case.test.ts create mode 100644 src/application/use-cases/fetch-versions.use-case.ts create mode 100644 src/application/use-cases/generate-brewfile.use-case.ts create mode 100644 src/application/use-cases/generate-script.use-case.test.ts create mode 100644 src/application/use-cases/generate-script.use-case.ts create mode 100644 src/application/use-cases/get-install-estimates.use-case.ts create mode 100644 src/application/use-cases/get-package-versions.use-case.ts create mode 100644 src/application/use-cases/get-packages-for-platform.use-case.ts create mode 100644 src/application/use-cases/get-preview-command.use-case.ts create mode 100644 src/application/use-cases/manage-bucket.use-case.test.ts create mode 100644 src/application/use-cases/manage-bucket.use-case.ts create mode 100644 src/application/use-cases/parse-ai-action.use-case.test.ts create mode 100644 src/application/use-cases/parse-ai-action.use-case.ts create mode 100644 src/application/use-cases/search-packages.use-case.ts create mode 100644 src/application/use-cases/share-script.use-case.test.ts create mode 100644 src/application/use-cases/share-script.use-case.ts create mode 100644 src/domain/entities/bucket.test.ts create mode 100644 src/domain/entities/bucket.ts create mode 100644 src/domain/entities/package.test.ts create mode 100644 src/domain/entities/package.ts create mode 100644 src/domain/entities/script.test.ts create mode 100644 src/domain/entities/script.ts create mode 100644 src/domain/repositories/package-repository.interface.ts create mode 100644 src/domain/repositories/version-repository.interface.ts create mode 100644 src/domain/services/script-generator.test.ts rename src/{lib => domain/services}/script-generator.ts (97%) create mode 100644 src/domain/value-objects/platform.ts create mode 100644 src/domain/value-objects/shell.ts create mode 100644 src/domain/value-objects/value-objects.test.ts create mode 100644 src/domain/value-objects/version.ts create mode 100644 src/infrastructure/adapters/ai/ai-provider.interface.ts create mode 100644 src/infrastructure/adapters/ai/groq.adapter.ts create mode 100644 src/infrastructure/adapters/ai/openai.adapter.ts create mode 100644 src/infrastructure/adapters/browser/script-download.adapter.ts create mode 100644 src/infrastructure/adapters/catalog/static-package.repository.test.ts create mode 100644 src/infrastructure/adapters/catalog/static-package.repository.ts create mode 100644 src/infrastructure/adapters/registries/apt.adapter.ts create mode 100644 src/infrastructure/adapters/registries/browser-version.repository.test.ts create mode 100644 src/infrastructure/adapters/registries/browser-version.repository.ts create mode 100644 src/infrastructure/adapters/registries/catalog-package.repository.ts create mode 100644 src/infrastructure/adapters/registries/homebrew.adapter.ts create mode 100644 src/infrastructure/adapters/registries/http-version.repository.ts create mode 100644 src/infrastructure/adapters/registries/npm.adapter.ts create mode 100644 src/infrastructure/adapters/registries/pypi.adapter.ts create mode 100644 src/infrastructure/adapters/registries/registry.interface.ts create mode 100644 src/infrastructure/adapters/sharing/file-script-share.adapter.ts create mode 100644 src/infrastructure/adapters/storage/local-storage.adapter.ts create mode 100644 src/infrastructure/adapters/storage/storage.interface.ts create mode 100644 src/infrastructure/config/client-container.ts create mode 100644 src/infrastructure/config/di-container.ts create mode 100644 src/presentation/components/chat-window.tsx create mode 100644 src/presentation/components/package-manager.tsx create mode 100644 src/presentation/components/script-output.tsx create mode 100644 src/presentation/hooks/use-client-use-cases.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd6afe5..c222945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: TypeScript type-check run: pnpm exec tsc --noEmit + - name: Unit tests with coverage + run: pnpm test:coverage + build: name: Build runs-on: ubuntu-latest diff --git a/README.md b/README.md index 4fa9321..de9847f 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,22 @@ Curated list of 2025's best development tools across 17+ categories: - **Bucket Management**: Save, review, and modify your tool selections - **Copy & Paste**: One-click script generation and copying +### Architecture + +SudoStart uses Hexagonal Architecture for its core workflows: + +- `src/domain` contains entities, value objects, repository interfaces, and script-generation domain services. +- `src/application` contains use cases and ports for script generation, AI chat, version fetching, bucket management, and script sharing. +- `src/infrastructure` contains adapters for Groq, future AI providers, package registries, storage, file-backed sharing, and dependency injection. + +API routes are kept thin: they handle HTTP validation, rate-limit headers, and response formatting, then delegate to use cases. + +More detail: + +- [Architecture overview](docs/architecture/hexagonal-overview.md) +- [Developer guide](docs/architecture/developer-guide.md) +- [ADR 0001](docs/adr/0001-hexagonal-architecture.md) + --- ## Demo diff --git a/docs/adr/0001-hexagonal-architecture.md b/docs/adr/0001-hexagonal-architecture.md new file mode 100644 index 0000000..82860e6 --- /dev/null +++ b/docs/adr/0001-hexagonal-architecture.md @@ -0,0 +1,23 @@ +# ADR 0001: Hexagonal Architecture + +## Status + +Accepted + +## Context + +SudoStart needs clearer boundaries between script generation, package/version resolution, AI provider calls, API handlers, and React presentation. The previous structure made Groq, registry HTTP calls, and bucket mutation behavior hard to test or replace independently. + +## Decision + +Adopt Hexagonal Architecture with three primary layers: + +- `src/domain`: pure entities, value objects, repository interfaces, and domain services. +- `src/application`: use cases, DTOs, and incoming/outgoing ports. +- `src/infrastructure`: adapters for AI providers, registries, storage, sharing, and dependency wiring. + +Next.js API routes remain in `src/app/api`, but they should only handle HTTP concerns such as request parsing, validation, rate-limit headers, and response formatting. Business workflows are delegated to application use cases. + +## Consequences + +New AI providers can be added by implementing `AIProvider`. New package/version registries can be added by implementing the repository or registry adapter interfaces. UI and API code should consume use cases rather than depending directly on SDKs, registry helpers, or domain internals. diff --git a/docs/architecture/developer-guide.md b/docs/architecture/developer-guide.md new file mode 100644 index 0000000..3720248 --- /dev/null +++ b/docs/architecture/developer-guide.md @@ -0,0 +1,48 @@ +# Developer Guide + +## Where New Code Goes + +- New business rules belong in `src/domain`. +- New workflows belong in `src/application/use-cases`. +- New external integrations belong in `src/infrastructure/adapters`. +- New UI belongs in `src/presentation/components` with a thin export from `src/components` when preserving existing imports is useful. +- API routes should parse requests, validate HTTP concerns, set headers, and delegate to use cases. + +## Package Catalog Access + +Do not import `appCatalog` from React components or application use cases. Use the package repository instead: + +```ts +import { clientContainer } from '@/infrastructure/config/client-container'; + +const packages = clientContainer.packageRepository.findForPlatformSync(os); +``` + +Server-side code should use `container.packageRepository`. + +## Version Fetching + +UI code should use `FetchVersionsUseCase` through `clientContainer`. Server API routes should use the server `container`. + +## AI Action Parsing + +AI response parsing and validation belongs to `ParseAIActionUseCase`. React components should only apply the parsed action to UI state. + +## Testing + +Run: + +```bash +pnpm test +pnpm test:coverage +``` + +Coverage is configured for domain and application layers with an 80% threshold. + +## Boundaries Checklist + +- No React imports in `domain` or `application`. +- No SDK, `fetch`, filesystem, or browser storage usage in `domain`. +- No direct `appCatalog` imports in components. +- New adapters implement ports or repository interfaces. +- Components call use cases or repositories through browser-safe infrastructure bindings. diff --git a/docs/architecture/hexagonal-overview.md b/docs/architecture/hexagonal-overview.md new file mode 100644 index 0000000..4b01d63 --- /dev/null +++ b/docs/architecture/hexagonal-overview.md @@ -0,0 +1,64 @@ +# Hexagonal Architecture Overview + +SudoStart is organized around ports and adapters so core behavior can be tested without React, Next.js, Groq, browser storage, or registry HTTP calls. + +## Layers + +### Domain + +`src/domain` contains the core model: + +- Entities: `PackageEntity`, `Script`, `Bucket` +- Value objects: `Platform`, `Shell`, `Version` +- Repository interfaces: `PackageRepository`, `VersionRepository` +- Domain services: script generation and install cost estimation + +Domain code should not import React components, API routes, Zustand stores, SDK clients, or browser APIs. + +### Application + +`src/application` contains use cases and ports: + +- `GenerateScriptUseCase` +- `ChatWithAIUseCase` +- `FetchVersionsUseCase` +- `ManageBucketUseCase` +- `ParseAIActionUseCase` +- `ShareScriptUseCase` + +Use cases coordinate domain objects and outgoing ports. They should not know whether the caller is a React component, API route, CLI command, or test. + +### Infrastructure + +`src/infrastructure` contains adapters: + +- AI: Groq and future OpenAI adapter +- Catalog: static package repository +- Registries: HTTP and browser version repositories +- Storage: local storage adapter +- Sharing: file-backed script share adapter +- Config: server and browser-safe dependency containers + +External dependencies belong here. + +### Presentation + +`src/presentation` contains UI implementations. `src/components` exposes thin compatibility wrappers so existing imports remain stable while presentation code moves out of the legacy component layer. + +Presentation code can call browser-safe use cases through `clientContainer`, but it should not import static catalog data or server adapters directly. + +## Dependency Direction + +Allowed direction: + +`presentation/api -> application -> domain` + +Adapters implement ports and are injected from infrastructure containers. Domain and application code do not import infrastructure implementations. + +## Adding a Provider or Registry + +To add a new AI provider, implement `AIProvider` and wire it in the DI container. + +To add a new version source, implement `VersionRepository` or extend the HTTP version repository source map. + +To add a new package catalog backend, implement `PackageRepository` and replace the static package repository binding. diff --git a/docs/tickets/github-issue-template.md b/docs/tickets/github-issue-template.md new file mode 100644 index 0000000..2585eda --- /dev/null +++ b/docs/tickets/github-issue-template.md @@ -0,0 +1,149 @@ +# GitHub Issue Template + +Copy and paste the following into a new GitHub issue: + +--- + +## Title +`[ARCHITECTURE] Refactor codebase to Hexagonal Architecture (Ports & Adapters)` + +--- + +## Body + +```markdown +## Overview +Refactor the SudoStart codebase from its current layered/component-based architecture to **Hexagonal Architecture** (Ports and Adapters pattern). This will improve testability, maintainability, and make the codebase more extensible for future features. + +## Current Problems +- ❌ Business logic (script generation, package resolution) scattered across React components and API routes +- ❌ Tight coupling to Groq AI (hard to swap providers) +- ❌ Tight coupling to specific registries (Homebrew, apt) - Windows support difficult +- ❌ Testing business logic requires mounting React components +- ❌ Adding new features requires changes across multiple layers + +## Proposed Solution +Implement Hexagonal Architecture with clear separation: + +**Domain Layer (Core)** +- Entities: Package, Script, Bucket +- Value Objects: Version, Platform, Shell +- Domain Services: ScriptGenerator +- Repository Interfaces + +**Application Layer** +- Use Cases: GenerateScript, ChatWithAI, FetchVersions, ManageBucket +- DTOs for input/output +- Application Services + +**Infrastructure Layer** +- Adapters: GroqAI, Homebrew, Apt, LocalStorage +- API Routes (thin HTTP handlers) +- UI Components (thin presentation layer) + +## Implementation Phases + +### Phase 1: Domain Layer Foundation [1 day] +- [ ] Create domain entities (Package, Script, Bucket) +- [ ] Create value objects (Version, Platform, Shell) +- [ ] Define repository interfaces +- [ ] Write unit tests (target: 100% coverage) + +### Phase 2: Application Layer [1-1.5 days] +- [ ] Implement use cases +- [ ] Create DTOs +- [ ] Write unit tests with mocked repositories + +### Phase 3: Infrastructure Adapters [1.5-2 days] +- [ ] Refactor existing registry helpers into adapters +- [ ] Create AI provider abstraction +- [ ] Implement storage adapters +- [ ] Write integration tests + +### Phase 4: UI/API Refactoring [1 day] +- [ ] Refactor components to be thin (delegate to use cases) +- [ ] Refactor API routes to be thin HTTP handlers +- [ ] Implement dependency injection +- [ ] Update existing hooks + +### Phase 5: Testing & Documentation [0.5-1 day] +- [ ] Achieve >80% test coverage +- [ ] Create architecture documentation +- [ ] Add ADR (Architecture Decision Record) +- [ ] Update README + +## Benefits +✅ **Testability**: Unit test business logic without React components +✅ **Flexibility**: Swap AI providers (Groq ↔ OpenAI ↔ Anthropic) with single file change +✅ **Platform Support**: Add Windows (Winget/Chocolatey) by creating new adapter +✅ **Maintainability**: Changes to UI don't affect business logic +✅ **Team Scaling**: Frontend and backend developers work independently + +## Success Metrics +- [ ] All existing functionality preserved (feature parity) +- [ ] Unit test coverage > 80% for domain and application layers +- [ ] Component file size < 100 lines (business logic extracted) +- [ ] Zero business logic in UI components +- [ ] New AI provider can be added in < 30 minutes +- [ ] New package registry can be added in < 1 hour + +## Risks +| Risk | Mitigation | +|------|------------| +| Breaking changes | Feature branch, comprehensive tests, gradual rollout | +| Increased complexity | Document architecture decisions, team training | +| Time overrun | Break into phases, deliver incrementally | + +## Resources +- Detailed ticket: `/docs/tickets/hexagonal-architecture-refactor.md` +- Visual reference: `/docs/tickets/hexagonal-refactor-diagram.md` +- [Hexagonal Architecture by Alistair Cockburn](https://alistair.cockburn.us/hexagonal-architecture/) + +## Estimated Effort +**3-5 days** across 5 phases + +## Priority +**High** - Blocks Windows support and AI provider flexibility + +## Labels +`architecture`, `refactoring`, `technical-debt`, `hexagonal`, `enhancement` +``` + +--- + +## Quick Commands to Create Issue + +### Option 1: Using GitHub CLI (if installed) +```bash +gh issue create \ + --title "[ARCHITECTURE] Refactor codebase to Hexagonal Architecture (Ports & Adapters)" \ + --label "architecture,refactoring,technical-debt,enhancement" \ + --body-file docs/tickets/github-issue-body.md +``` + +### Option 2: Using GitHub Web Interface +1. Go to: https://github.com/[username]/sudo-start/issues/new +2. Copy the title above +3. Copy the body above (between the triple backticks) +4. Add labels: `architecture`, `refactoring`, `technical-debt`, `enhancement` +5. Submit issue + +--- + +## Related Files Created + +1. **Detailed Ticket**: `docs/tickets/hexagonal-architecture-refactor.md` + - Full implementation plan + - Phase-by-phase breakdown + - Directory structure + - Acceptance criteria + +2. **Visual Reference**: `docs/tickets/hexagonal-refactor-diagram.md` + - Before/after architecture diagrams + - Data flow examples + - Testing strategy + - Migration path + +3. **This Template**: `docs/tickets/github-issue-template.md` + - Ready-to-use GitHub issue format +``` diff --git a/docs/tickets/hexagonal-architecture-refactor.md b/docs/tickets/hexagonal-architecture-refactor.md new file mode 100644 index 0000000..a247e6f --- /dev/null +++ b/docs/tickets/hexagonal-architecture-refactor.md @@ -0,0 +1,366 @@ +# Ticket: Refactor to Hexagonal Architecture + +**Status:** 📋 Planned +**Priority:** High +**Estimated Effort:** 3-5 days +**Labels:** `architecture`, `refactoring`, `technical-debt`, `hexagonal` + +--- + +## Overview + +Refactor the SudoStart codebase from its current layered/component-based architecture to **Hexagonal Architecture** (Ports and Adapters pattern). This will improve testability, maintainability, and make the codebase more extensible for future features like Windows support, different AI providers, and additional package managers. + +--- + +## Current Architecture Issues + +1. **Mixed Concerns**: Business logic (script generation, package resolution) is scattered across React components and API routes +2. **Tight Coupling**: Direct dependencies on: + - Groq AI (hard to swap for OpenAI/Anthropic) + - Specific registries (Homebrew, apt) - hard to add Windows support + - Zustand store structure throughout components +3. **Testing Difficulty**: Business logic is intertwined with React components, making unit testing nearly impossible +4. **Extensibility Problems**: Adding new features requires changes across multiple layers + +--- + +## Proposed Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ INFRASTRUCTURE LAYER (Adapters) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ UI Layer │ │ API Layer │ │ External Services│ │ +│ │ (React/ │ │ (Next.js │ │ (Groq, Registries│ │ +│ │ Next.js) │ │ Routes) │ │ Storage) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ APPLICATION LAYER (Use Cases) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ • Generate Installation Script │ │ +│ │ • Chat with AI Assistant │ │ +│ │ • Fetch Package Versions │ │ +│ │ • Manage User Bucket │ │ +│ │ • Share Scripts │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DOMAIN LAYER (Core) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Entities │ │ Services │ │ Repositories │ │ +│ │ (Package, │ │ (Script │ │ (Interfaces) │ │ +│ │ Script, │ │ Generator) │ │ │ │ +│ │ Bucket) │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Phases + +### Phase 1: Domain Layer Foundation ⏳ +**Estimated Time:** 1 day + +#### Tasks: +- [ ] Create `src/domain/entities/` directory structure +- [ ] Implement `Package` entity with: + - Properties: id, name, description, category, platforms, installCommands + - Methods: supportsPlatform(), getInstallCommand() +- [ ] Implement `Script` entity with: + - Properties: content, packages, targetPlatform, shell + - Methods: validate(), toString() +- [ ] Implement `Bucket` entity with: + - Properties: items, createdAt, updatedAt + - Methods: add(), remove(), clear(), getItems() +- [ ] Create value objects: + - `Version` (with validation) + - `Platform` (enum: macOS, Linux, Windows) + - `Shell` (enum: bash, zsh, fish) +- [ ] Define repository interfaces: + - `PackageRepository` (findById, findByCategory, search) + - `VersionRepository` (fetchLatest, validateVersion) + +#### Acceptance Criteria: +- All entities have unit tests with 100% coverage +- Entities are pure TypeScript (no React, no external dependencies) +- Value objects are immutable + +--- + +### Phase 2: Application Layer (Use Cases) ⏳ +**Estimated Time:** 1-1.5 days + +#### Tasks: +- [ ] Create `src/application/ports/` for incoming ports +- [ ] Implement use cases: + - `GenerateScriptUseCase` + - Input: packages[], platform, shell + - Output: Script entity + - `ChatWithAIUseCase` + - Input: message, context (bucket contents) + - Output: AI response + - `FetchVersionsUseCase` + - Input: packageIds[] + - Output: version map + - `ManageBucketUseCase` + - Methods: addPackage, removePackage, clear, getContents + - `ShareScriptUseCase` + - Input: script content + - Output: shareable URL/token +- [ ] Create application services for complex workflows +- [ ] Define DTOs for use case inputs/outputs + +#### Acceptance Criteria: +- Use cases are independent of UI framework +- Each use case can be tested in isolation +- Clear separation between application and domain logic + +--- + +### Phase 3: Infrastructure Layer - Adapters ⏳ +**Estimated Time:** 1.5-2 days + +#### Tasks: +- [ ] Create `src/infrastructure/adapters/` structure: + ``` + adapters/ + ├── ai/ + │ ├── groq-adapter.ts + │ ├── openai-adapter.ts (stub for future) + │ └── ai-provider.interface.ts + ├── registries/ + │ ├── homebrew-adapter.ts + │ ├── apt-adapter.ts + │ ├── npm-adapter.ts + │ ├── pypi-adapter.ts + │ └── registry.interface.ts + ├── storage/ + │ ├── local-storage-adapter.ts + │ └── storage.interface.ts + └── http/ + └── http-client.ts + ``` +- [ ] Refactor existing registry helpers into adapters +- [ ] Implement repository pattern for data access +- [ ] Create AI provider abstraction layer +- [ ] Implement storage adapters for persistence + +#### Acceptance Criteria: +- Adapters implement domain repository interfaces +- Groq AI can be swapped without changing use cases +- New registries can be added by implementing interface +- All external dependencies are in infrastructure layer + +--- + +### Phase 4: UI and API Refactoring ⏳ +**Estimated Time:** 1 day + +#### Tasks: +- [ ] Refactor React components to be thin: + - Components only handle UI state and events + - Business logic delegated to use cases +- [ ] Refactor API routes: + - Routes only handle HTTP concerns (validation, headers) + - Delegate to use cases +- [ ] Update Zustand store to work with new architecture +- [ ] Implement dependency injection container +- [ ] Update existing hooks to use new use cases + +#### Files to Refactor: +- [ ] `src/components/script-output.tsx` → Use `GenerateScriptUseCase` +- [ ] `src/components/chat-window.tsx` → Use `ChatWithAIUseCase` +- [ ] `src/components/package-manager.tsx` → Use `ManageBucketUseCase` +- [ ] `src/app/api/chat/route.ts` → Delegate to use case +- [ ] `src/app/api/versions/route.ts` → Delegate to use case +- [ ] `src/lib/script-generator.ts` → Move to domain service + +#### Acceptance Criteria: +- Components are under 100 lines each +- No business logic in UI components +- API routes are thin HTTP handlers +- All dependencies injected via DI container + +--- + +### Phase 5: Testing and Documentation ⏳ +**Estimated Time:** 0.5-1 day + +#### Tasks: +- [ ] Write unit tests for all domain entities +- [ ] Write unit tests for all use cases (with mocked repositories) +- [ ] Write integration tests for adapters +- [ ] Update existing E2E tests if any +- [ ] Create architecture documentation +- [ ] Add ADR (Architecture Decision Record) for hexagonal architecture +- [ ] Update README with new architecture overview + +#### Testing Strategy: +``` +Domain Tests (Jest/Vitest) +├── entities/ +│ ├── package.test.ts +│ ├── script.test.ts +│ └── bucket.test.ts +└── services/ + └── script-generator.test.ts + +Application Tests +└── use-cases/ + ├── generate-script.test.ts + ├── chat-with-ai.test.ts + └── manage-bucket.test.ts + +Integration Tests +└── adapters/ + ├── groq-adapter.test.ts + └── homebrew-adapter.test.ts +``` + +--- + +## Directory Structure After Refactoring + +``` +src/ +├── domain/ # Domain Layer (Core) +│ ├── entities/ +│ │ ├── package.ts +│ │ ├── script.ts +│ │ └── bucket.ts +│ ├── value-objects/ +│ │ ├── version.ts +│ │ ├── platform.ts +│ │ └── shell.ts +│ ├── repositories/ +│ │ ├── package-repository.interface.ts +│ │ └── version-repository.interface.ts +│ └── services/ +│ └── script-generator.ts +│ +├── application/ # Application Layer +│ ├── ports/ +│ │ ├── incoming/ +│ │ │ ├── generate-script.port.ts +│ │ │ ├── chat-with-ai.port.ts +│ │ │ └── manage-bucket.port.ts +│ │ └── outgoing/ +│ │ ├── ai-provider.port.ts +│ │ └── storage.port.ts +│ ├── use-cases/ +│ │ ├── generate-script.use-case.ts +│ │ ├── chat-with-ai.use-case.ts +│ │ ├── fetch-versions.use-case.ts +│ │ └── manage-bucket.use-case.ts +│ └── dto/ +│ ├── generate-script.dto.ts +│ └── chat-message.dto.ts +│ +├── infrastructure/ # Infrastructure Layer +│ ├── adapters/ +│ │ ├── ai/ +│ │ │ ├── groq.adapter.ts +│ │ │ └── openai.adapter.ts +│ │ ├── registries/ +│ │ │ ├── homebrew.adapter.ts +│ │ │ ├── apt.adapter.ts +│ │ │ └── npm.adapter.ts +│ │ └── storage/ +│ │ └── local-storage.adapter.ts +│ ├── api/ +│ │ ├── chat/ +│ │ │ └── route.ts +│ │ ├── versions/ +│ │ │ └── route.ts +│ │ └── script-share/ +│ │ └── route.ts +│ └── config/ +│ └── di-container.ts +│ +├── presentation/ # Presentation Layer (UI) +│ ├── components/ +│ │ ├── boot-screen.tsx +│ │ ├── package-manager.tsx +│ │ ├── script-output.tsx +│ │ └── chat-window.tsx +│ ├── hooks/ +│ │ ├── use-generate-script.ts +│ │ ├── use-chat.ts +│ │ └── use-bucket.ts +│ └── store/ +│ └── app-store.ts +│ +└── shared/ # Shared Utilities + ├── types/ + ├── utils/ + └── constants/ +``` + +--- + +## Benefits After Completion + +### Immediate Benefits: +- ✅ **Testability**: Unit test business logic without React components +- ✅ **Flexibility**: Swap AI providers (Groq ↔ OpenAI ↔ Anthropic) with single file change +- ✅ **Platform Support**: Add Windows (Winget/Chocolatey) by creating new adapter +- ✅ **Maintainability**: Changes to UI don't affect business logic + +### Long-term Benefits: +- ✅ **Team Scaling**: Frontend and backend developers work independently +- ✅ **Feature Velocity**: New features require changes only in specific layers +- ✅ **Code Quality**: Clear boundaries prevent spaghetti code +- ✅ **Onboarding**: New developers understand architecture quickly + +--- + +## Risks and Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking changes during refactor | High | Create feature branch, comprehensive tests, gradual rollout | +| Increased complexity | Medium | Document architecture decisions, provide team training | +| Time overrun | Medium | Break into phases, deliver incrementally | +| Performance regression | Low | Benchmark before/after, optimize adapters | + +--- + +## Success Metrics + +- [ ] All existing functionality preserved (feature parity) +- [ ] Unit test coverage > 80% for domain and application layers +- [ ] Component file size < 100 lines (business logic extracted) +- [ ] Zero business logic in UI components +- [ ] New AI provider can be added in < 30 minutes +- [ ] New package registry can be added in < 1 hour + +--- + +## Related Resources + +- [Hexagonal Architecture by Alistair Cockburn](https://alistair.cockburn.us/hexagonal-architecture/) +- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [Domain-Driven Design by Eric Evans](https://domainlanguage.com/ddd/reference/) +- Current codebase analysis: See `/docs/architecture/current-state.md` + +--- + +## Notes + +- This is a **non-breaking refactor** - all existing features must continue working +- Consider using **feature flags** for gradual rollout if needed +- Document any deviations from pure hexagonal architecture (if necessary for Next.js constraints) +- Update CI/CD pipeline to run new test suites + +--- + +**Created:** 2026-07-28 +**Author:** Development Team +**Reviewers:** [To be assigned] diff --git a/docs/tickets/hexagonal-architecture-remaining-work.md b/docs/tickets/hexagonal-architecture-remaining-work.md new file mode 100644 index 0000000..1a7eee3 --- /dev/null +++ b/docs/tickets/hexagonal-architecture-remaining-work.md @@ -0,0 +1,517 @@ +# Ticket: Complete Hexagonal Architecture Implementation + +**Status:** 📋 Ready for Development +**Priority:** High +**Estimated Effort:** 2-3 days +**Labels:** `architecture`, `refactoring`, `hexagonal`, `technical-debt`, `testing` + +**Parent Ticket:** [Hexagonal Architecture Refactor](./hexagonal-architecture-refactor.md) +**Current Compliance:** ~67% Complete + +--- + +## Overview + +The core hexagonal architecture (Domain, Application, and Infrastructure layers) has been successfully implemented. This ticket covers the **remaining work** needed to achieve full compliance with hexagonal architecture principles and complete the refactoring initiative. + +## Current State + +### ✅ Completed (67%) +- Domain Layer: Entities, Value Objects, Services ✓ +- Application Layer: Use Cases, DTOs, Ports ✓ +- Infrastructure Layer: Adapters (AI, Registries, Storage) ✓ +- DI Container: Manual dependency injection ✓ +- Entity Tests: Package, Bucket, Script ✓ + +### ❌ Remaining (33%) +- UI Layer: Business logic still in components +- Repository Pattern: Static catalog not abstracted +- Testing: Missing use case, adapter, and integration tests +- Legacy Cleanup: Old lib/ folder still in use +- Documentation: No ADR or architecture docs + +--- + +## Phase 1: Extract Business Logic from Components ⏳ +**Estimated Time:** 0.5-1 day + +### Current Issues + +Components contain business logic that should be in use cases: + +#### 1.1 `chat-window.tsx` (350 lines) +**Problem:** Contains AI response parsing and action execution logic + +**Current Code (lines 64-113):** +```typescript +const parseAndExecuteAction = useCallback((fullContent: string) => { + // Business logic: Parse JSON, validate packages, modify bucket + const jsonMatch = fullContent.match(/```json\n([\s\S]*?)\n```/); + if (jsonMatch) { + const action = JSON.parse(jsonMatch[1]); + // Directly manipulates bucket state + if (action.action === 'add') { + action.packageIds.forEach((id: string) => { + const pkg = appCatalog.find(p => p.id === id); // Direct catalog access + if (pkg) addToBucket(pkg); + }); + } + } +}, [bucket, addToBucket, removeFromBucket]); +``` + +**Required Changes:** +- [ ] Create `ParseAIResponseUseCase` in application layer +- [ ] Move JSON parsing logic to use case +- [ ] Move package validation to domain service +- [ ] Component should only call use case and handle UI state +- [ ] Add unit tests for the use case + +#### 1.2 `package-manager.tsx` (565 lines) +**Problem:** Contains version fetching and command preview generation + +**Current Code (lines 348-365):** +```typescript +const handleVersionDropdownOpen = async () => { + // Direct API call from component + const res = await fetch(`/api/versions?tool=${toolId}`); + const data = await res.json(); + // Business logic: Process versions + const processedVersions = data.versions?.map((v: string) => { + return v.replace(/^(v?)(\d+\.\d+\.\d+).*$/, '$1$2'); + }); + setVersions(processedVersions); +}; +``` + +**Current Code (lines 374-392):** +```typescript +const getPreviewCommand = () => { + // Business logic: Template string replacement + const cmd = selectedVersion + ? template.replaceAll('${VERSION}', selectedVersion) + .replaceAll('${VERSION_NO_V}', selectedVersion.replace(/^v/, '')) + : template; + return cmd; +}; +``` + +**Required Changes:** +- [ ] Create `FetchPackageVersionsUseCase` (already exists, verify usage) +- [ ] Move version processing logic to use case or domain service +- [ ] Move command template rendering to domain service +- [ ] Component should receive processed data from use case +- [ ] Add unit tests for template rendering logic + +#### 1.3 `script-output.tsx` (454 lines) +**Problem:** Contains script generation orchestration + +**Current Code:** +```typescript +// Imports from legacy lib/ +import { generateScript, generateBrewfile, downloadScript } from '@/lib/script-generator'; + +// Component orchestrates script generation +const handleGenerate = async () => { + const script = generateScript(bucket, os, shell); // Direct call + setGeneratedScript(script); +}; +``` + +**Required Changes:** +- [ ] Use `GenerateScriptUseCase` from DI container +- [ ] Remove dependency on `@/lib/script-generator` +- [ ] Component should be thin, only display results +- [ ] Add error handling at component level + +### Acceptance Criteria +- [ ] No business logic in React components +- [ ] Components only handle UI state and events +- [ ] All business logic delegated to use cases +- [ ] Components under 150 lines each +- [ ] Unit tests for extracted use cases + +--- + +## Phase 2: Implement PackageRepository Pattern ⏳ +**Estimated Time:** 0.5 day + +### Current Issues + +Static catalog is accessed directly throughout the codebase: + +**Problem Pattern:** +```typescript +// Direct import and usage +import { appCatalog } from '@/lib/apps'; + +// In components +const pkg = appCatalog.find(p => p.id === id); +const categoryApps = appCatalog.filter(p => p.category === category); +``` + +**Problems:** +- No abstraction over data source +- Impossible to swap catalog implementation (e.g., API-driven catalog) +- Hard to test (must mock module imports) +- Violates Dependency Inversion Principle + +### Required Changes + +#### 2.1 Create Repository Interface +**File:** `src/domain/repositories/package-repository.interface.ts` + +```typescript +export interface PackageRepository { + findById(id: string): Promise; + findByCategory(category: string): Promise; + findAll(): Promise; + search(query: string): Promise; + findByPlatform(platform: Platform): Promise; +} +``` + +#### 2.2 Create Static Catalog Adapter +**File:** `src/infrastructure/adapters/catalog/static-package.repository.ts` + +```typescript +export class StaticPackageRepository implements PackageRepository { + private catalog: PackageEntity[]; + + constructor() { + // Convert static appCatalog to entities + this.catalog = appCatalog.map(pkg => PackageEntity.fromDTO(pkg)); + } + + async findById(id: string): Promise { + return this.catalog.find(p => p.id === id) || null; + } + + async findByCategory(category: string): Promise { + return this.catalog.filter(p => p.category === category); + } + + // ... other methods +} +``` + +#### 2.3 Update DI Container +**File:** `src/infrastructure/config/di-container.ts` + +```typescript +import { StaticPackageRepository } from '../adapters/catalog/static-package.repository'; + +export const container = { + packageRepository: new StaticPackageRepository(), + // ... other dependencies +}; +``` + +#### 2.4 Update Components +**Files to update:** +- [ ] `chat-window.tsx` - Use repository instead of direct catalog access +- [ ] `package-manager.tsx` - Use repository for package lookups +- [ ] `boot-screen.tsx` - Use repository if accessing packages +- [ ] Any other components importing `appCatalog` + +### Acceptance Criteria +- [ ] `PackageRepository` interface defined in domain layer +- [ ] `StaticPackageRepository` implements interface +- [ ] No direct imports of `appCatalog` in components +- [ ] All package access goes through repository +- [ ] Repository can be mocked for testing +- [ ] Unit tests for repository implementation + +--- + +## Phase 3: Add Comprehensive Testing ⏳ +**Estimated Time:** 0.5-1 day + +### Current State +- Only 3 test files exist (entity tests) +- No use case tests +- No adapter tests +- No integration tests +- CI/CD doesn't run tests + +### Required Tests + +#### 3.1 Application Layer Tests +**Directory:** `src/application/use-cases/__tests__/` + +- [ ] `generate-script.use-case.test.ts` + - Test successful script generation + - Test with empty bucket + - Test with unsupported platform + - Mock `PackageRepository` and `ScriptGenerator` + +- [ ] `chat-with-ai.use-case.test.ts` + - Test successful chat + - Test error handling + - Test rate limiting + - Mock `AIProvider` + +- [ ] `fetch-versions.use-case.test.ts` + - Test successful version fetch + - Test caching behavior + - Test error handling + - Mock `VersionRepository` + +- [ ] `share-script.use-case.test.ts` + - Test successful share + - Test validation + - Mock `ScriptShareAdapter` + +- [ ] `manage-bucket.use-case.test.ts` (if created) + - Test add, remove, clear operations + - Test duplicate prevention + +#### 3.2 Infrastructure Adapter Tests +**Directory:** `src/infrastructure/adapters/__tests__/` + +- [ ] `groq-adapter.test.ts` + - Test successful API call + - Test error handling + - Test rate limiting + - Mock `groq-sdk` + +- [ ] `http-version.repository.test.ts` + - Test version fetching for different tools + - Test caching + - Test error handling + - Mock `fetch` or use MSW + +- [ ] `local-storage-adapter.test.ts` + - Test storage operations + - Test serialization/deserialization + - Mock `localStorage` + +- [ ] `static-package.repository.test.ts` + - Test findById + - Test findByCategory + - Test search + +#### 3.3 Integration Tests +**Directory:** `src/__tests__/integration/` + +- [ ] `script-generation.flow.test.ts` + - Test full flow: bucket → script generation + - Use real domain services, mock external APIs + +- [ ] `chat.flow.test.ts` + - Test full chat flow with AI + - Mock Groq API + +#### 3.4 Update CI/CD +**File:** `.github/workflows/ci.yml` + +```yaml +- name: Run Tests + run: pnpm test + +- name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info +``` + +### Acceptance Criteria +- [ ] >80% test coverage for application layer +- [ ] >80% test coverage for domain layer (already have entity tests) +- [ ] All adapters have unit tests with mocked dependencies +- [ ] At least 2 integration tests for critical flows +- [ ] CI/CD runs tests on every PR +- [ ] Coverage report generated + +--- + +## Phase 4: Legacy Code Cleanup ⏳ +**Estimated Time:** 0.5 day + +### Current Issues + +Legacy `/src/lib/` folder still contains mixed concerns: + +``` +src/lib/ +├── apps/ # 3,173 lines - Static catalog +│ ├── index.ts +│ ├── ides.ts +│ ├── browsers.ts +│ └── ... (18 more files) +├── registries/ # Legacy registry adapters +│ ├── homebrew.ts +│ ├── apt.ts +│ ├── npm.ts +│ └── pypi.ts +├── store.ts # 147 lines - Zustand with business logic +├── script-generator.ts # Re-exports from domain (bridge file) +├── security.ts # 185 lines - Security utilities +└── ... # Other utilities +``` + +### Required Changes + +#### 4.1 Migrate Static Catalog +- [ ] Move `src/lib/apps/` to `src/infrastructure/data/catalog/` +- [ ] Update imports in `StaticPackageRepository` +- [ ] Ensure no other files import from old location + +#### 4.2 Remove Legacy Registry Adapters +- [ ] Verify `src/lib/registries/` is no longer used +- [ ] Check if any components still import from here +- [ ] Remove if safe, or migrate remaining logic + +#### 4.3 Refactor Store +**File:** `src/lib/store.ts` + +**Current Issues:** +```typescript +// Store mixes persistence with business logic +addToBucket: (pkg) => set((state) => { + // Business logic in store + const bucket = createBucket(state.bucket).add(PackageEntity.fromDTO(pkg)); + return { bucket: bucketToPackages(bucket) }; +}), +``` + +**Required Changes:** +- [ ] Create `ManageBucketUseCase` if not exists +- [ ] Store should only handle persistence and UI state +- [ ] Business logic should delegate to use case +- [ ] Or use domain events pattern + +#### 4.4 Remove Bridge Files +- [ ] Remove `src/lib/script-generator.ts` (re-exports from domain) +- [ ] Update all imports to use domain directly or DI container + +#### 4.5 Security Utilities +- [ ] Keep `src/lib/security.ts` but consider moving to `src/shared/security/` +- [ ] Or create `SecurityService` in domain layer + +### Acceptance Criteria +- [ ] No imports from `@/lib/apps` (use repository instead) +- [ ] No imports from `@/lib/registries` (use adapters instead) +- [ ] Store only handles persistence, no business logic +- [ ] Bridge files removed +- [ ] All imports updated + +--- + +## Phase 5: Documentation ⏳ +**Estimated Time:** 0.5 day + +### Required Documentation + +#### 5.1 Architecture Decision Record (ADR) +**File:** `docs/adr/001-hexagonal-architecture.md` + +```markdown +# ADR 001: Hexagonal Architecture + +## Status +Accepted + +## Context +SudoStart needed better separation of concerns, testability, and extensibility. + +## Decision +Implement Hexagonal Architecture (Ports and Adapters pattern). + +## Consequences +- Positive: Better testability, clear boundaries, easy to extend +- Negative: Initial learning curve, more files/directories +``` + +#### 5.2 Architecture Overview +**File:** `docs/architecture/overview.md` + +- [ ] Explain the three layers (Domain, Application, Infrastructure) +- [ ] Describe the dependency rule +- [ ] Document the DI container approach +- [ ] Include architecture diagrams + +#### 5.3 Developer Guide +**File:** `docs/development/guide.md` + +- [ ] How to add a new use case +- [ ] How to add a new adapter +- [ ] How to add a new entity +- [ ] Testing guidelines +- [ ] Code organization rules + +#### 5.4 Update README +**File:** `README.md` + +- [ ] Add architecture section +- [ ] Link to detailed documentation +- [ ] Add diagram showing architecture + +### Acceptance Criteria +- [ ] ADR created and committed +- [ ] Architecture overview document complete +- [ ] Developer guide with examples +- [ ] README updated with architecture info + +--- + +## Success Metrics + +- [ ] **Component Size:** All components under 150 lines +- [ ] **Test Coverage:** >80% for domain and application layers +- [ ] **No Direct Catalog Access:** All package access via repository +- [ ] **Clean Imports:** No imports from legacy `lib/` (except utilities) +- [ ] **Documentation:** ADR + architecture docs complete +- [ ] **CI/CD:** Tests run on every PR with coverage report + +--- + +## Implementation Order + +**Recommended sequence:** + +1. **Phase 2** (PackageRepository) - Unblocks component refactoring +2. **Phase 1** (Extract Business Logic) - Depends on repository +3. **Phase 4** (Legacy Cleanup) - Do alongside Phase 1 +4. **Phase 3** (Testing) - Test as you refactor +5. **Phase 5** (Documentation) - Document what you've built + +**Alternative: Parallel Work** +- Developer A: Phases 1 & 2 (Components + Repository) +- Developer B: Phase 3 (Testing) +- Developer C: Phase 5 (Documentation) +- Together: Phase 4 (Legacy cleanup) + +--- + +## Risks and Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking existing functionality | High | Comprehensive tests, feature flags, gradual rollout | +| Performance regression | Medium | Benchmark before/after, optimize adapters | +| Developer confusion | Low | Documentation, code review, pair programming | +| Scope creep | Medium | Stick to ticket scope, create follow-up tickets | + +--- + +## Related Tickets + +- Parent: [Hexagonal Architecture Refactor](./hexagonal-architecture-refactor.md) +- Related: [Component Refactoring Guidelines](./component-refactoring.md) (if exists) + +--- + +## Notes + +- This ticket assumes the core architecture is already in place +- Focus is on completing the implementation, not redesigning +- Keep changes minimal and focused +- Update tests as you refactor (don't break existing tests) + +--- + +**Created:** 2026-07-28 +**Author:** Development Team +**Reviewers:** [To be assigned] +**Estimated Completion:** 2-3 days diff --git a/docs/tickets/hexagonal-refactor-diagram.md b/docs/tickets/hexagonal-refactor-diagram.md new file mode 100644 index 0000000..82bc809 --- /dev/null +++ b/docs/tickets/hexagonal-refactor-diagram.md @@ -0,0 +1,227 @@ +# Hexagonal Architecture Refactor - Visual Reference + +## Before vs After + +### Current Architecture (Layered) +``` +┌────────────────────────────────────────────┐ +│ UI Components (React) │ +│ - BootScreen │ +│ - PackageManager │ +│ - ScriptOutput │ +│ - ChatWindow │ +├────────────────────────────────────────────┤ +│ State (Zustand) │ +│ - bucket │ +│ - generatedScript │ +│ - currentStep │ +├────────────────────────────────────────────┤ +│ Business Logic (Mixed) │ +│ - script-generator.ts │ +│ - security.ts │ +│ - API routes │ +├────────────────────────────────────────────┤ +│ External Services │ +│ - Groq API │ +│ - Homebrew │ +│ - Apt │ +└────────────────────────────────────────────┘ + +Problems: +❌ Business logic scattered +❌ Hard to test +❌ Tight coupling +❌ Difficult to extend +``` + +### After: Hexagonal Architecture +``` +┌─────────────────────────────────────────────────────────────┐ +│ PRIMARY ADAPTERS (Drive the Application) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ React UI │ │ Next.js │ │ CLI Tool │ │ +│ │ Components │ │ API Routes │ │ (Future) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ Uses + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ APPLICATION LAYER (Use Cases) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ GenerateScriptUseCase │ │ +│ │ ChatWithAIUseCase │ │ +│ │ FetchVersionsUseCase │ │ +│ │ ManageBucketUseCase │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ Uses + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DOMAIN LAYER (Core Business Logic) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Package │ │ Script │ │ Bucket │ │ +│ │ Entity │ │ Entity │ │ Entity │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ ScriptGenerator (Domain Service) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ Depends on (Interfaces) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SECONDARY ADAPTERS (Driven by Application) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Groq │ │ Homebrew │ │ LocalStorage │ │ +│ │ Adapter │ │ Adapter │ │ Adapter │ │ +│ ├──────────────┤ ├──────────────┤ ├──────────────────┤ │ +│ │ OpenAI │ │ Apt │ │ IndexedDB │ │ +│ │ Adapter │ │ Adapter │ │ Adapter │ │ +│ │ (Future) │ │ (Future) │ │ (Future) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + +Benefits: +✅ Clear separation of concerns +✅ Easy to test (mock adapters) +✅ Loose coupling +✅ Easy to extend (add adapters) +``` + +## Dependency Rule + +``` +Dependencies can only point INWARD + + Infrastructure ───────┐ + │ │ + ▼ │ + Application ──────────┼─── Domain (Core) + │ │ + ▼ │ + Domain (No deps) ◄────┘ + +Domain Layer knows NOTHING about: +- React +- Next.js +- Groq +- Homebrew +- LocalStorage + +It only knows its own entities and interfaces. +``` + +## Data Flow Example: Generate Script + +``` +1. User clicks "Generate Script" + │ + ▼ +2. React Component calls GenerateScriptUseCase + │ + ▼ +3. Use Case validates input (DTO) + │ + ▼ +4. Use Case calls ScriptGenerator domain service + │ + ▼ +5. Domain Service creates Script entity + │ + ▼ +6. Domain Service uses PackageRepository interface + │ + ▼ +7. Infrastructure Adapter (Homebrew/Apt) implements interface + │ + ▼ +8. Script entity returned to Use Case + │ + ▼ +9. Use Case returns ScriptDTO to UI + │ + ▼ +10. React Component displays script +``` + +## Port and Adapter Examples + +### Incoming Port (Driven by UI) +```typescript +// application/ports/generate-script.port.ts +interface GenerateScriptPort { + execute(input: GenerateScriptInput): Promise; +} + +// Used by React component +const { generateScript } = useGenerateScript(); +``` + +### Outgoing Port (Drives Infrastructure) +```typescript +// domain/repositories/package-repository.interface.ts +interface PackageRepository { + findById(id: string): Promise; + findByCategory(category: string): Promise; + search(query: string): Promise; +} + +// Implemented by adapters +class HomebrewPackageRepository implements PackageRepository { } +class AptPackageRepository implements PackageRepository { } +``` + +## Testing Strategy + +``` +Domain Tests (Fast, No Dependencies) +├── entities/package.test.ts +├── entities/script.test.ts +└── services/script-generator.test.ts + └── Mock PackageRepository + +Application Tests (Fast, Mocked) +├── use-cases/generate-script.test.ts +│ └── Mock all outgoing ports +└── use-cases/chat-with-ai.test.ts + └── Mock AI provider + +Integration Tests (Slower, Real Dependencies) +├── adapters/groq-adapter.test.ts +├── adapters/homebrew-adapter.test.ts +└── adapters/local-storage-adapter.test.ts + +E2E Tests (Full Stack) +└── user-journey.test.ts +``` + +## Migration Path + +``` +Phase 1: Create Domain Layer (Parallel to existing code) + ├─ Create new directory structure + ├─ Implement entities + └─ Write unit tests + +Phase 2: Create Application Layer + ├─ Define use cases + ├─ Create DTOs + └─ Write unit tests + +Phase 3: Create Adapters + ├─ Refactor existing code into adapters + ├─ Implement repository interfaces + └─ Write integration tests + +Phase 4: Migrate UI + ├─ Refactor components to use use cases + ├─ Remove business logic from components + └─ Update E2E tests + +Phase 5: Cleanup + ├─ Remove old code + ├─ Update documentation + └─ Performance testing +``` diff --git a/docs/tickets/remaining-work-checklist.md b/docs/tickets/remaining-work-checklist.md new file mode 100644 index 0000000..5df9f11 --- /dev/null +++ b/docs/tickets/remaining-work-checklist.md @@ -0,0 +1,155 @@ +# Hexagonal Architecture - Remaining Work Checklist + +Quick reference checklist for completing the hexagonal architecture implementation. + +## 📊 Progress Tracker + +| Phase | Task | Status | Owner | +|-------|------|--------|-------| +| **Phase 1** | Extract Business Logic from Components | ⬜ | | +| | 1.1 Refactor chat-window.tsx | ⬜ | | +| | 1.2 Refactor package-manager.tsx | ⬜ | | +| | 1.3 Refactor script-output.tsx | ⬜ | | +| **Phase 2** | Implement PackageRepository Pattern | ⬜ | | +| | 2.1 Create repository interface | ⬜ | | +| | 2.2 Create StaticPackageRepository | ⬜ | | +| | 2.3 Update DI container | ⬜ | | +| | 2.4 Update components | ⬜ | | +| **Phase 3** | Add Comprehensive Testing | ⬜ | | +| | 3.1 Application layer tests | ⬜ | | +| | 3.2 Infrastructure adapter tests | ⬜ | | +| | 3.3 Integration tests | ⬜ | | +| | 3.4 Update CI/CD | ⬜ | | +| **Phase 4** | Legacy Code Cleanup | ⬜ | | +| | 4.1 Migrate static catalog | ⬜ | | +| | 4.2 Remove legacy registries | ⬜ | | +| | 4.3 Refactor store | ⬜ | | +| | 4.4 Remove bridge files | ⬜ | | +| **Phase 5** | Documentation | ⬜ | | +| | 5.1 Create ADR | ⬜ | | +| | 5.2 Architecture overview | ⬜ | | +| | 5.3 Developer guide | ⬜ | | +| | 5.4 Update README | ⬜ | | + +**Overall Progress:** 0% ⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜ + +--- + +## ✅ Pre-Flight Checks + +Before starting each phase: + +### Phase 1: Component Refactoring +- [ ] Review current component code +- [ ] Identify all business logic to extract +- [ ] Design use case interfaces +- [ ] Check for dependencies on legacy code + +### Phase 2: Repository Pattern +- [ ] List all places using `appCatalog` directly +- [ ] Design repository interface methods +- [ ] Plan migration strategy (gradual vs. big bang) + +### Phase 3: Testing +- [ ] Verify test framework setup (Jest/Vitest/node:test) +- [ ] Set up coverage reporting +- [ ] Create test utilities (mocks, fixtures) + +### Phase 4: Legacy Cleanup +- [ ] Search for all imports from `@/lib/*` +- [ ] Identify safe-to-remove files +- [ ] Plan migration of store logic + +### Phase 5: Documentation +- [ ] Review existing documentation +- [ ] Gather architecture diagrams +- [ ] Prepare code examples + +--- + +## 🎯 Daily Standup Questions + +1. What did you complete yesterday? +2. What are you working on today? +3. Any blockers or dependencies? +4. Are we on track for the 2-3 day estimate? + +--- + +## 🚨 Blocker Log + +| Date | Blocker | Impact | Resolution | +|------|---------|--------|------------| +| | | | | + +--- + +## 📝 Notes & Decisions + +### Decision Log + +| Date | Decision | Rationale | Impact | +|------|----------|-----------|--------| +| | | | | + +### Technical Notes + +- +- +- + +--- + +## 🔍 Code Review Checklist + +For each PR: + +- [ ] Business logic extracted from components +- [ ] New use cases have unit tests +- [ ] No direct `appCatalog` imports +- [ ] Repository pattern used correctly +- [ ] Legacy code removed or deprecated +- [ ] Documentation updated +- [ ] No breaking changes to public API +- [ ] Performance impact assessed + +--- + +## 📈 Success Metrics Tracking + +| Metric | Target | Current | Status | +|--------|--------|---------|--------| +| Component Size (<150 lines) | 100% | 0% | ⬜ | +| Test Coverage (Domain) | >80% | 0% | ⬜ | +| Test Coverage (Application) | >80% | 0% | ⬜ | +| No Direct Catalog Access | 0 files | TBD | ⬜ | +| Documentation Complete | 100% | 0% | ⬜ | + +--- + +## 🎓 Learning Resources + +- [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) +- [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) +- [Dependency Inversion Principle](https://stackify.com/dependency-inversion-principle/) + +--- + +## 🆘 Help & Escalation + +**If stuck for >2 hours:** +1. Check the detailed ticket: `docs/tickets/hexagonal-architecture-remaining-work.md` +2. Review the visual diagrams: `docs/tickets/hexagonal-refactor-diagram.md` +3. Ask in team chat +4. Schedule pair programming session + +**Escalation path:** +1. Tech Lead +2. Architecture Team +3. External Consultant (if needed) + +--- + +**Last Updated:** 2026-07-28 +**Next Review:** Daily standup diff --git a/docs/tickets/remaining-work-github-issue.md b/docs/tickets/remaining-work-github-issue.md new file mode 100644 index 0000000..da3156f --- /dev/null +++ b/docs/tickets/remaining-work-github-issue.md @@ -0,0 +1,78 @@ +# GitHub Issue: Complete Hexagonal Architecture Implementation + +Copy and paste the following into a new GitHub issue: + +--- + +## Title +`[ARCHITECTURE] Complete Hexagonal Architecture Implementation (33% Remaining)` + +--- + +## Body + +```markdown +## Overview +The core hexagonal architecture has been successfully implemented (67% complete). This ticket covers the **remaining 33%** needed to achieve full compliance with hexagonal architecture principles. + +## Current Status + +### ✅ Completed (67%) +- Domain Layer: Entities, Value Objects, Services ✓ +- Application Layer: Use Cases, DTOs, Ports ✓ +- Infrastructure Layer: Adapters (AI, Registries, Storage) ✓ +- DI Container: Manual dependency injection ✓ +- Entity Tests: Package, Bucket, Script ✓ + +### ❌ Remaining (33%) +- UI Layer: Business logic still in components +- Repository Pattern: Static catalog not abstracted +- Testing: Missing use case, adapter, and integration tests +- Legacy Cleanup: Old lib/ folder still in use +- Documentation: No ADR or architecture docs + +## Implementation Phases + +### Phase 1: Extract Business Logic from Components [0.5-1 day] +- [ ] Refactor `chat-window.tsx` - Move AI response parsing to use case +- [ ] Refactor `package-manager.tsx` - Move version fetching to use case +- [ ] Refactor `script-output.tsx` - Use DI container instead of direct calls +- [ ] Ensure all components under 150 lines +- [ ] Add unit tests for extracted use cases + +### Phase 2: Implement PackageRepository Pattern [0.5 day] +- [ ] Create `PackageRepository` interface in domain layer +- [ ] Create `StaticPackageRepository` adapter +- [ ] Update DI container with repository +- [ ] Replace all direct `appCatalog` imports with repository usage +- [ ] Add unit tests for repository + +### Phase 3: Add Comprehensive Testing [0.5-1 day] +- [ ] Application layer tests (all use cases) +- [ ] Infrastructure adapter tests (with mocked dependencies) +- [ ] Integration tests for critical flows +- [ ] Update CI/CD to run tests and generate coverage reports +- [ ] Target: >80% coverage for domain and application layers + +### Phase 4: Legacy Code Cleanup [0.5 day] +- [ ] Migrate `src/lib/apps/` to infrastructure layer +- [ ] Remove or migrate `src/lib/registries/` +- [ ] Refactor `src/lib/store.ts` to remove business logic +- [ ] Remove bridge files (e.g., `script-generator.ts`) +- [ ] Update all imports + +### Phase 5: Documentation [0.5 day] +- [ ] Create ADR (Architecture Decision Record) +- [ ] Write architecture overview document +- [ ] Create developer guide +- [ ] Update README with architecture section + +## Success Metrics +- [ ] All components under 150 lines +- [ ] >80% test coverage for domain and application layers +- [ ] Zero direct `appCatalog` imports in components +- [ ] No business logic in React components +- [ ] Complete documentation (ADR + guides) +- [ ] CI/CD runs tests with coverage reports + +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..43f553a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + "coverage/**", "next-env.d.ts", ]), ]); diff --git a/package.json b/package.json index a486af5..3910981 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "clsx": "^2.1.1", @@ -28,10 +30,12 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/react-syntax-highlighter": "^15.5.13", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9", "eslint-config-next": "16.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" }, "packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 892c56e..ba2eab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) eslint: specifier: ^9 version: 9.39.4(jiti@2.6.1) @@ -72,6 +75,9 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@20.19.39)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.39)(jiti@2.6.1)) packages: @@ -117,10 +123,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -134,6 +148,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -150,15 +169,32 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -369,6 +405,13 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.2.0': + resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^2.0.0-alpha.3 + '@emnapi/runtime': ^2.0.0-alpha.3 + '@next/env@16.1.1': resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} @@ -439,14 +482,112 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} hasBin: true + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -541,9 +682,18 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -751,6 +901,44 @@ packages: cpu: [x64] os: [win32] + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -815,9 +1003,16 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -890,6 +1085,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1035,6 +1234,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1182,6 +1384,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1190,6 +1395,10 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1264,6 +1473,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1378,6 +1592,9 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -1532,6 +1749,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -1540,6 +1769,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1686,6 +1918,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1856,6 +2095,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1938,6 +2182,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1972,6 +2220,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1983,6 +2234,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} engines: {node: '>=18'} @@ -2001,6 +2256,10 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -2089,6 +2348,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2156,6 +2420,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2166,6 +2433,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2241,10 +2514,25 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2348,6 +2636,90 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.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 + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + web-streams-polyfill@4.0.0-beta.3: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} @@ -2379,6 +2751,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2488,8 +2865,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -2501,6 +2882,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -2526,12 +2911,30 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -2542,6 +2945,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': dependencies: eslint: 9.39.4(jiti@2.6.1) @@ -2722,6 +3130,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@16.1.1': {} '@next/eslint-plugin-next@16.1.1': @@ -2766,12 +3181,67 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.139.0': {} + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -2850,10 +3320,22 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -3057,6 +3539,61 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@20.19.39)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.39)(jiti@2.6.1)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@20.19.39)(jiti@2.6.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@20.19.39)(jiti@2.6.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -3153,8 +3690,16 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} asynckit@0.4.0: {} @@ -3219,6 +3764,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3415,6 +3962,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3647,10 +4196,16 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} event-target-shim@5.0.1: {} + expect-type@1.4.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -3679,6 +4234,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3723,6 +4282,9 @@ snapshots: fsevents@2.3.2: optional: true + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3869,6 +4431,8 @@ snapshots: highlightjs-vue@1.0.0: {} + html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} humanize-ms@1.2.1: @@ -4023,6 +4587,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -4034,6 +4611,8 @@ snapshots: jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -4154,6 +4733,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -4529,6 +5118,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.16: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -4615,6 +5206,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4658,12 +5251,16 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} picomatch@4.0.4: {} + picomatch@4.0.5: {} + playwright-core@1.59.1: {} playwright@1.59.1: @@ -4680,6 +5277,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.9: dependencies: nanoid: 3.3.11 @@ -4815,6 +5418,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4932,12 +5556,18 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5029,11 +5659,22 @@ snapshots: tapable@2.3.2: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5195,6 +5836,46 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@8.1.5(@types/node@20.19.39)(jiti@2.6.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.24 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.39 + fsevents: 2.3.3 + jiti: 2.6.1 + + vitest@4.1.10(@types/node@20.19.39)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@20.19.39)(jiti@2.6.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@20.19.39)(jiti@2.6.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@20.19.39)(jiti@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.39 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + transitivePeerDependencies: + - msw + web-streams-polyfill@4.0.0-beta.3: {} webidl-conversions@3.0.1: {} @@ -5249,6 +5930,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yallist@3.1.1: {} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index f74cda6..b185a5f 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,31 +1,11 @@ import { NextRequest } from 'next/server'; -import Groq from 'groq-sdk'; -import { checkRateLimit, isValidGroqApiKey } from '@/lib/security'; +import { container } from '@/infrastructure/config/di-container'; +import { checkRateLimit } from '@/lib/security'; -let groq: Groq | null = null; - -// Rate limit: 10 requests per minute per IP const RATE_LIMIT_MAX = 10; const RATE_LIMIT_WINDOW_MS = 60 * 1000; -function getGroqClient() { - if (!groq) { - const apiKey = process.env.GROQ_API_KEY; - if (!apiKey) { - throw new Error('GROQ_API_KEY is not defined'); - } - // SECURITY: Validate API key format to catch misconfigurations early - if (!isValidGroqApiKey(apiKey)) { - console.error('[Security] Invalid GROQ_API_KEY format detected'); - throw new Error('Invalid GROQ_API_KEY format'); - } - groq = new Groq({ apiKey }); - } - return groq; -} - function getClientIP(request: NextRequest): string { - // Get IP from various headers, fallback to 'unknown' const forwarded = request.headers.get('x-forwarded-for'); const realIP = request.headers.get('x-real-ip'); return forwarded?.split(',')[0]?.trim() || realIP || 'unknown'; @@ -33,15 +13,14 @@ function getClientIP(request: NextRequest): string { export async function POST(request: NextRequest) { try { - // SECURITY: Rate limiting check const clientIP = getClientIP(request); const rateLimit = checkRateLimit(`chat:${clientIP}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS); - + if (!rateLimit.allowed) { return new Response( JSON.stringify({ error: 'Rate limit exceeded. Please try again later.', - retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000), }), { status: 429, @@ -49,99 +28,19 @@ export async function POST(request: NextRequest) { 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - } + }, + }, ); } - const { messages, bucketContext } = await request.json(); - - if (!messages || !Array.isArray(messages)) { + const { messages, bucketContext = [] } = await request.json(); + if (!Array.isArray(messages)) { return new Response(JSON.stringify({ error: 'Invalid messages' }), { status: 400 }); } - const bucketSection = bucketContext && bucketContext.length > 0 - ? `\n\nCurrent bucket (already selected by the user): ${bucketContext.join(', ')}.` - : '\n\nCurrent bucket: empty.'; - - const systemPrompt = { - role: 'system' as const, - content: `You are "Root", an expert Unix system administrator and helpful AI assistant for the SudoStart application. + const { stream } = await container.chatWithAIUseCase.execute({ messages, bucketContext }); -Your purpose is to help users set up their development environment by recommending software packages and tools. - -Start every response with a JSON object. Do not output plain text outside the JSON. -The JSON Schema is: -{ - "response": "Your conversational response to the user here (use Markdown)", - "action": { - "type": "add" | "remove", - "packageIds": ["id1", "id2", "id3:version"] - } -} -The "action" field is OPTIONAL. Only include it if the user explicitly asks to add or remove packages. - -Full Package Catalog: -IDEs: windsurf, cursor, zed, vscode, vim, intellij -Browsers: zen-browser, arc, vivaldi, brave, google-chrome, microsoft-edge, firefox -Runtimes: nvm, nodejs, npm, python3, ruby, php, kotlin, rust, go, java, cpp -Package Managers: pnpm, yarn, pyenv, rbenv, sdkman -Build Tools: make, cmake, gradle, maven -Containers: docker, docker-desktop, podman, kubectl, minikube -Cloud CLIs: aws-cli, gcloud, azure-cli -Databases: postgresql, mysql, mariadb, sqlite3, redis, mongodb -Terminals: iterm2, warp, alacritty, kitty, hyper, ghostty -Frameworks: react, vue, angular, nextjs, django, flask, express -DevOps: jenkins, prometheus, docker-compose -Data Science: jupyter, tensorflow, pandas, numpy, matplotlib -Mobile: flutter, react-native, ionic, cordova, xcode -Game Dev: godot, blender, unity, unreal-engine -Desktop Dev: electron, tauri, qt -Web Servers: nginx, apache -Utilities: jq, wget, htop, tmux, openssh, ngrok, insomnia -Communication: zoom, microsoft-teams, telegram, slack, discord -Productivity: rectangle, raycast, 1password, bitwarden, docker-desktop -Tools: git, curl, zsh, oh-my-zsh, terraform, ansible, github-cli, postman, figma -${bucketSection} - -Keep responses short and terminal-like. Be opinionated and helpful. Always be aware of what's already in the bucket.`, - }; - - const client = getGroqClient(); - const stream = await client.chat.completions.create({ - messages: [systemPrompt, ...messages], - model: 'llama-3.3-70b-versatile', - temperature: 0.7, - max_tokens: 1024, - stream: true, - }); - - // Stream the response as Server-Sent Events - const encoder = new TextEncoder(); - const readable = new ReadableStream({ - async start(controller) { - try { - let fullContent = ''; - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta?.content ?? ''; - if (delta) { - fullContent += delta; - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ delta, done: false })}\n\n`) - ); - } - } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ delta: '', done: true, full: fullContent })}\n\n`) - ); - controller.close(); - } catch (err) { - controller.error(err); - } - }, - }); - - return new Response(readable, { + return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -152,22 +51,17 @@ Keep responses short and terminal-like. Be opinionated and helpful. Always be aw }, }); } catch (error) { - // SECURITY: Never leak the API key in error messages const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const safeErrorMessage = errorMessage.includes('gsk_') ? 'Internal server error' : errorMessage; - - // Log with masked key if present + if (errorMessage.includes('gsk_')) { console.error('Chat API error: [REDACTED - API key detected in error]'); } else { console.error('Chat API error:', error); } - - return new Response( - JSON.stringify({ error: safeErrorMessage }), - { status: 500 } - ); + + return new Response(JSON.stringify({ error: safeErrorMessage }), { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/script-share/route.ts b/src/app/api/script-share/route.ts index 44b8469..2d1b123 100644 --- a/src/app/api/script-share/route.ts +++ b/src/app/api/script-share/route.ts @@ -1,17 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { writeFile, readFile, mkdir } from 'fs/promises'; -import { existsSync } from 'fs'; -import path from 'path'; -import os from 'os'; -import { checkRateLimit, isValidScriptId, sanitizeScriptId } from '@/lib/security'; - -// SECURITY: Use a more secure directory (not world-readable /tmp) -// In production, use a dedicated directory with proper permissions -const STORE_DIR = process.env.SUDOSTART_STORE_DIR - || path.join(os.tmpdir(), 'sudostart-scripts'); -const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - -// Rate limit: 20 requests per minute per IP for POST, 60 for GET +import { container } from '@/infrastructure/config/di-container'; +import { checkRateLimit, isValidScriptId } from '@/lib/security'; + +const TTL_MS = 24 * 60 * 60 * 1000; const RATE_LIMIT_POST_MAX = 20; const RATE_LIMIT_GET_MAX = 60; const RATE_LIMIT_WINDOW_MS = 60 * 1000; @@ -22,79 +13,16 @@ function getClientIP(request: NextRequest): string { return forwarded?.split(',')[0]?.trim() || realIP || 'unknown'; } -interface ScriptEntry { - script: string; - createdAt: number; - meta: { os: string; packages: string[] }; -} - -async function ensureDir() { - if (!existsSync(STORE_DIR)) { - await mkdir(STORE_DIR, { recursive: true }); - } -} - -function generateId(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let id = ''; - for (let i = 0; i < 10; i++) { - id += chars[Math.floor(Math.random() * chars.length)]; - } - return id; -} - -async function readEntry(id: string): Promise { - try { - // SECURITY: Validate ID format before using in file path - if (!isValidScriptId(id)) { - console.warn(`[Security] Invalid script ID attempted: ${sanitizeScriptId(id)}`); - return null; - } - - // SECURITY: Use sanitized ID for file path - const sanitizedId = sanitizeScriptId(id); - const file = path.join(STORE_DIR, `${sanitizedId}.json`); - - // SECURITY: Verify the resolved path is within STORE_DIR (path traversal check) - const resolvedPath = path.resolve(file); - const resolvedStoreDir = path.resolve(STORE_DIR); - if (!resolvedPath.startsWith(resolvedStoreDir)) { - console.warn(`[Security] Path traversal attempt detected: ${id}`); - return null; - } - - const raw = await readFile(file, 'utf-8'); - const entry: ScriptEntry = JSON.parse(raw); - if (Date.now() - entry.createdAt > TTL_MS) return null; - return entry; - } catch { - return null; - } -} - -async function writeEntry(id: string, entry: ScriptEntry) { - // SECURITY: Validate ID format - if (!isValidScriptId(id)) { - throw new Error('Invalid script ID format'); - } - - await ensureDir(); - const sanitizedId = sanitizeScriptId(id); - const file = path.join(STORE_DIR, `${sanitizedId}.json`); - await writeFile(file, JSON.stringify(entry), 'utf-8'); -} - export async function POST(request: NextRequest) { try { - // SECURITY: Rate limiting check const clientIP = getClientIP(request); const rateLimit = checkRateLimit(`script-share:post:${clientIP}`, RATE_LIMIT_POST_MAX, RATE_LIMIT_WINDOW_MS); - + if (!rateLimit.allowed) { return NextResponse.json( { error: 'Rate limit exceeded. Please try again later.', - retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000), }, { status: 429, @@ -102,58 +30,42 @@ export async function POST(request: NextRequest) { 'X-RateLimit-Limit': String(RATE_LIMIT_POST_MAX), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - } + }, + }, ); } - const { script, os: osName, packages } = await request.json(); - - // SECURITY: Validate script is a string and has reasonable length - if (!script || typeof script !== 'string') { - return NextResponse.json({ error: 'Invalid script' }, { status: 400 }); - } - - // SECURITY: Limit script size (max 1MB) - if (script.length > 1024 * 1024) { - return NextResponse.json({ error: 'Script too large (max 1MB)' }, { status: 400 }); - } - - // SECURITY: Validate packages is an array of strings - if (packages !== undefined && (!Array.isArray(packages) || !packages.every(p => typeof p === 'string'))) { + const { script, os, packages } = await request.json(); + if (packages !== undefined && (!Array.isArray(packages) || !packages.every((pkg) => typeof pkg === 'string'))) { return NextResponse.json({ error: 'Invalid packages format' }, { status: 400 }); } - const id = generateId(); - await writeEntry(id, { - script, - createdAt: Date.now(), - meta: { os: osName ?? 'unknown', packages: packages ?? [] }, - }); + const { id } = await container.shareScriptUseCase.execute({ script, os, packages }); return NextResponse.json({ id }, { headers: { 'X-RateLimit-Limit': String(RATE_LIMIT_POST_MAX), 'X-RateLimit-Remaining': String(rateLimit.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } + }, }); - } catch (err) { - console.error('script-share POST error:', err); - return NextResponse.json({ error: 'Failed to store script' }, { status: 500 }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to store script'; + const status = message.startsWith('Invalid') || message.startsWith('Script too large') ? 400 : 500; + console.error('script-share POST error:', error); + return NextResponse.json({ error: message }, { status }); } } export async function GET(request: NextRequest) { - // SECURITY: Rate limiting check const clientIP = getClientIP(request); const rateLimit = checkRateLimit(`script-share:get:${clientIP}`, RATE_LIMIT_GET_MAX, RATE_LIMIT_WINDOW_MS); - + if (!rateLimit.allowed) { return NextResponse.json( { error: 'Rate limit exceeded. Please try again later.', - retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000), }, { status: 429, @@ -161,8 +73,8 @@ export async function GET(request: NextRequest) { 'X-RateLimit-Limit': String(RATE_LIMIT_GET_MAX), 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - } + }, + }, ); } @@ -173,18 +85,17 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing id' }, { status: 400 }); } - // SECURITY: Validate ID format before reading if (!isValidScriptId(id)) { return NextResponse.json({ error: 'Invalid id format' }, { status: 400 }); } - const entry = await readEntry(id); + const entry = await container.scriptShareAdapter.findById(id); if (!entry) { const ua = request.headers.get('user-agent') ?? ''; const isCli = ua.toLowerCase().includes('curl') || ua.toLowerCase().includes('wget'); if (isCli) { return new NextResponse( - `#!/bin/bash\necho "Error: Script not found or expired (24h TTL)"\nexit 1\n`, + '#!/bin/bash\necho "Error: Script not found or expired (24h TTL)"\nexit 1\n', { status: 404, headers: { @@ -192,26 +103,24 @@ export async function GET(request: NextRequest) { 'X-RateLimit-Limit': String(RATE_LIMIT_GET_MAX), 'X-RateLimit-Remaining': String(rateLimit.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - } + }, + }, ); } + return NextResponse.json({ error: 'Script not found or expired' }, { status: 404, headers: { 'X-RateLimit-Limit': String(RATE_LIMIT_GET_MAX), 'X-RateLimit-Remaining': String(rateLimit.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } + }, }); } const ua = request.headers.get('user-agent') ?? ''; const raw = searchParams.get('raw') === '1'; - const isCli = - ua.toLowerCase().includes('curl') || - ua.toLowerCase().includes('wget') || - raw; + const isCli = ua.toLowerCase().includes('curl') || ua.toLowerCase().includes('wget') || raw; if (isCli) { return new NextResponse(entry.script, { @@ -237,6 +146,6 @@ export async function GET(request: NextRequest) { 'X-RateLimit-Limit': String(RATE_LIMIT_GET_MAX), 'X-RateLimit-Remaining': String(rateLimit.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } + }, }); -} \ No newline at end of file +} diff --git a/src/app/api/versions/route.ts b/src/app/api/versions/route.ts index 22d341a..4b778ad 100644 --- a/src/app/api/versions/route.ts +++ b/src/app/api/versions/route.ts @@ -1,11 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; +import { container } from '@/infrastructure/config/di-container'; +import { VERSION_SOURCES } from '@/infrastructure/adapters/registries/http-version.repository'; import { checkRateLimit } from '@/lib/security'; -// Cache for version data (5 minute TTL) -const versionCache: Record = {}; -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - -// Rate limit: 30 requests per minute per IP const RATE_LIMIT_MAX = 30; const RATE_LIMIT_WINDOW_MS = 60 * 1000; @@ -15,308 +12,86 @@ function getClientIP(request: NextRequest): string { return forwarded?.split(',')[0]?.trim() || realIP || 'unknown'; } -// Helper for GitHub releases -const githubReleases = (repo: string, filter: (tag: string) => boolean = () => true) => ({ - url: `https://api.github.com/repos/${repo}/releases?per_page=10`, - parser: (data: unknown) => { - const releases = data as { tag_name: string; prerelease: boolean }[]; - return releases - .filter((r) => !r.prerelease && filter(r.tag_name)) - .map((r) => r.tag_name) - .slice(0, 5); - }, -}); - -// Helper for EndOfLife.date API -const eolApi = (product: string) => ({ - url: `https://endoflife.date/api/${product}.json`, - parser: (data: unknown) => { - const releases = data as { cycle: string; latest: string }[]; - return releases.slice(0, 5).map((r) => r.latest); - }, -}); - -// Version source configurations -const VERSION_SOURCES: Record string[]; -}> = { - nodejs: { - url: 'https://nodejs.org/dist/index.json', - parser: (data) => { - const releases = data as { version: string; lts: boolean | string }[]; - // Get LTS versions only, limit to 5 - return releases - .filter((r) => r.lts) - .slice(0, 5) - .map((r) => r.version); - }, - }, - go: { - url: 'https://go.dev/dl/?mode=json', - parser: (data) => { - const releases = data as { version: string; stable: boolean }[]; - return releases - .filter((r) => r.stable) - .slice(0, 5) - .map((r) => r.version.replace('go', '')); - }, - }, - python: { - url: 'https://endoflife.date/api/python.json', - parser: (data) => { - const releases = data as { cycle: string; latest: string }[]; - return releases - .slice(0, 5) - .map((r) => r.latest); - }, - }, - rust: { - url: 'https://api.github.com/repos/rust-lang/rust/releases?per_page=5', - parser: (data) => { - const releases = data as { tag_name: string; prerelease: boolean }[]; - return releases - .filter((r) => !r.prerelease) - .map((r) => r.tag_name); - }, - }, - docker: { - url: 'https://api.github.com/repos/docker/cli/releases?per_page=5', - parser: (data) => { - const releases = data as { tag_name: string; prerelease: boolean }[]; - return releases - .filter((r) => !r.prerelease) - .map((r) => r.tag_name.replace('v', '')); - }, - }, - postgresql: eolApi('postgresql'), - redis: { - url: 'https://api.github.com/repos/redis/redis/releases?per_page=5', - parser: (data) => { - const releases = data as { tag_name: string; prerelease: boolean }[]; - return releases - .filter((r) => !r.prerelease) - .map((r) => r.tag_name); - }, - }, - mongodb: { - url: 'https://api.github.com/repos/mongodb/mongo/releases?per_page=5', - parser: (data) => { - const releases = data as { tag_name: string; prerelease: boolean }[]; - return releases - .filter((r) => !r.prerelease && r.tag_name.startsWith('r')) - .map((r) => r.tag_name.replace('r', '')); - }, - }, - flutter: { - url: 'https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json', - parser: (data) => { - const releases = (data as { releases: { version: string }[] }).releases; - return Array.from(new Set(releases.map((r) => r.version))).slice(0, 5); - }, - }, - // IDEs - vscode: githubReleases('microsoft/vscode'), - zed: githubReleases('zed-industries/zed'), - - // Tools - terraform: githubReleases('hashicorp/terraform'), - ansible: githubReleases('ansible/ansible'), - 'github-cli': githubReleases('cli/cli'), - - // Containers - podman: githubReleases('containers/podman'), - kubectl: githubReleases('kubernetes/kubernetes', (tag) => tag.startsWith('v')), - minikube: githubReleases('kubernetes/minikube'), - - // DevOps - jenkins: githubReleases('jenkinsci/jenkins'), - prometheus: githubReleases('prometheus/prometheus'), - 'docker-compose': githubReleases('docker/compose'), - - // Frameworks - react: githubReleases('facebook/react'), - vue: githubReleases('vuejs/core'), - angular: githubReleases('angular/angular'), - nextjs: githubReleases('vercel/next.js'), - django: githubReleases('django/django'), - flask: githubReleases('pallets/flask'), - express: githubReleases('expressjs/express'), - - // Web Servers - nginx: githubReleases('nginx/nginx'), - - // Game Dev - godot: githubReleases('godotengine/godot'), - blender: githubReleases('blender/blender'), - - // Desktop Dev - electron: githubReleases('electron/electron'), - tauri: githubReleases('tauri-apps/tauri'), - - // Mobile - 'react-native': githubReleases('facebook/react-native'), - - // Browsers (open source) - 'zen-browser': githubReleases('zen-browser/desktop'), - 'brave': githubReleases('brave/brave-browser'), - 'firefox': githubReleases('mozilla/gecko-dev', (tag) => tag.includes('FIREFOX') && tag.includes('_RELEASE')), - - // Terminals (open source) - 'alacritty': githubReleases('alacritty/alacritty'), - 'kitty': githubReleases('kovidgoyal/kitty'), - 'hyper': githubReleases('vercel/hyper'), - - // Tools & Utilities (open source) - 'git': githubReleases('git/git', (tag) => tag.startsWith('v') && !tag.includes('rc') && !tag.includes('beta')), - 'zsh': githubReleases('zsh-users/zsh'), - 'oh-my-zsh': githubReleases('ohmyzsh/ohmyzsh'), - 'curl': githubReleases('curl/curl'), - 'jq': githubReleases('jqlang/jq'), - 'htop': githubReleases('htop-dev/htop'), - 'tmux': githubReleases('tmux/tmux'), - - // Databases (open source) - 'mysql': githubReleases('mysql/mysql-server'), - 'mariadb': githubReleases('MariaDB/server', (tag) => tag.startsWith('v')), - - // Runtimes (open source) - 'nvm': githubReleases('nvm-sh/nvm'), - 'ruby': githubReleases('ruby/ruby', (tag) => tag.startsWith('v') && !tag.includes('preview') && !tag.includes('rc')), - 'php': githubReleases('php/php-src', (tag) => tag.startsWith('php-')), - 'kotlin': githubReleases('JetBrains/kotlin'), - 'java': githubReleases('openjdk/jdk', (tag) => tag.startsWith('jdk-')), - - // Cloud CLIs (open source) - 'aws-cli': githubReleases('aws/aws-cli', (tag) => tag.startsWith('v') && !tag.includes('dev')), - 'azure-cli': githubReleases('Azure/azure-cli'), - - // Web Servers (open source) - 'apache': githubReleases('apache/httpd'), - - // Data Science (open source) - 'jupyter': githubReleases('jupyterlab/jupyterlab'), - 'tensorflow': githubReleases('tensorflow/tensorflow', (tag) => tag.startsWith('v')), - 'pandas': githubReleases('pandas-dev/pandas'), - 'numpy': githubReleases('numpy/numpy'), - 'matplotlib': githubReleases('matplotlib/matplotlib'), - - // IDEs (open source) - 'vim': githubReleases('vim/vim'), -}; - export async function GET(request: NextRequest) { - // SECURITY: Rate limiting check - const clientIP = getClientIP(request); - const rateLimit = checkRateLimit(`versions:${clientIP}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS); - - if (!rateLimit.allowed) { - return NextResponse.json( - { - error: 'Rate limit exceeded. Please try again later.', - retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000) + const clientIP = getClientIP(request); + const rateLimit = checkRateLimit(`versions:${clientIP}`, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS); + + if (!rateLimit.allowed) { + return NextResponse.json( + { + error: 'Rate limit exceeded. Please try again later.', + retryAfter: Math.ceil((rateLimit.resetTime - Date.now()) / 1000), + }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), }, - { - status: 429, - headers: { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - } - ); - } - - const { searchParams } = new URL(request.url); - const tool = searchParams.get('tool'); - - if (!tool) { - return NextResponse.json( - { error: 'Missing "tool" query parameter' }, - { status: 400 } - ); - } - - const source = VERSION_SOURCES[tool.toLowerCase()]; - if (!source) { - return NextResponse.json( - { error: `Unsupported tool: ${tool}. Supported: ${Object.keys(VERSION_SOURCES).join(', ')}` }, - { status: 400 } - ); - } - - // Check cache - const cached = versionCache[tool]; - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - return NextResponse.json({ - versions: cached.data, - cached: true, - tool - }); + }, + ); + } + + const { searchParams } = new URL(request.url); + const tool = searchParams.get('tool'); + + if (!tool) { + return NextResponse.json({ error: 'Missing "tool" query parameter' }, { status: 400 }); + } + + if (!VERSION_SOURCES[tool.toLowerCase()]) { + return NextResponse.json( + { error: `Unsupported tool: ${tool}. Supported: ${Object.keys(VERSION_SOURCES).join(', ')}` }, + { status: 400 }, + ); + } + + const cachedBeforeFetch = container.versionRepository.getCached(tool); + + try { + const { versions } = await container.fetchVersionsUseCase.execute({ packageIds: [tool] }); + + return NextResponse.json({ + versions: versions[tool], + cached: Boolean(cachedBeforeFetch), + tool, + }, { + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + }, + }); + } catch (error) { + console.error(`Error fetching versions for ${tool}:`, error); + + const cached = container.versionRepository.getCached(tool); + if (cached) { + return NextResponse.json({ + versions: cached, + cached: true, + stale: true, + tool, + }, { + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + }, + }); } - try { - const response = await fetch(source.url, { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'SudoStart-App', - }, - next: { revalidate: 300 }, // ISR cache for 5 min - }); - - if (!response.ok) { - throw new Error(`Failed to fetch versions: ${response.status}`); - } - - const data = await response.json(); - const versions = source.parser(data); - - // Update cache - versionCache[tool] = { - data: versions, - timestamp: Date.now(), - }; - - return NextResponse.json({ - versions, - cached: false, - tool - }, { - headers: { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), - 'X-RateLimit-Remaining': String(rateLimit.remaining), - 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - }); - } catch (error) { - console.error(`Error fetching versions for ${tool}:`, error); - - // Return cached data if available, even if stale - if (cached) { - return NextResponse.json({ - versions: cached.data, - cached: true, - stale: true, - tool - }, { - headers: { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), - 'X-RateLimit-Remaining': String(rateLimit.remaining), - 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - }); - } - - return NextResponse.json( - { error: 'Failed to fetch versions', tool }, - { - status: 500, - headers: { - 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), - 'X-RateLimit-Remaining': String(rateLimit.remaining), - 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), - } - } - ); - } + return NextResponse.json( + { error: 'Failed to fetch versions', tool }, + { + status: 500, + headers: { + 'X-RateLimit-Limit': String(RATE_LIMIT_MAX), + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetTime / 1000)), + }, + }, + ); + } } diff --git a/src/app/globals.css b/src/app/globals.css index 1b1469f..e9d7b33 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -57,8 +57,8 @@ --card-foreground: oklch(0.96 0.005 240); --popover: oklch(0.23 0.012 240); --popover-foreground: oklch(0.96 0.005 240); - --primary: oklch(0.72 0.15 155); - --primary-foreground: oklch(0.16 0.02 155); + --primary: oklch(0.6 0.14 255); + --primary-foreground: oklch(0.98 0.01 255); --secondary: oklch(0.28 0.012 240); --secondary-foreground: oklch(0.96 0.005 240); --muted: oklch(0.27 0.012 240); @@ -68,23 +68,23 @@ --destructive: oklch(0.62 0.2 25); --border: oklch(0.31 0.012 240); --input: oklch(0.28 0.012 240); - --ring: oklch(0.72 0.15 155); - --chart-1: oklch(0.72 0.15 155); - --chart-2: oklch(0.6 0.15 200); - --chart-3: oklch(0.7 0.14 120); - --chart-4: oklch(0.75 0.12 60); - --chart-5: oklch(0.6 0.16 300); + --ring: oklch(0.6 0.14 255); + --chart-1: oklch(0.6 0.14 255); + --chart-2: oklch(0.55 0.13 200); + --chart-3: oklch(0.65 0.13 120); + --chart-4: oklch(0.7 0.12 60); + --chart-5: oklch(0.55 0.15 300); --sidebar: oklch(0.21 0.012 240); --sidebar-foreground: oklch(0.96 0.005 240); - --sidebar-primary: oklch(0.72 0.15 155); - --sidebar-primary-foreground: oklch(0.16 0.02 155); + --sidebar-primary: oklch(0.6 0.14 255); + --sidebar-primary-foreground: oklch(0.98 0.01 255); --sidebar-accent: oklch(0.3 0.014 240); --sidebar-accent-foreground: oklch(0.96 0.005 240); --sidebar-border: oklch(0.31 0.012 240); - --sidebar-ring: oklch(0.72 0.15 155); + --sidebar-ring: oklch(0.6 0.14 255); /* Accent helper colors */ - --terminal-green: oklch(0.78 0.15 155); + --terminal-green: oklch(0.7 0.14 255); --terminal-cyan: oklch(0.72 0.13 200); --terminal-yellow: oklch(0.82 0.13 85); --terminal-bg: oklch(0.23 0.012 240); @@ -105,34 +105,34 @@ --card-foreground: oklch(0.22 0.015 250); --popover: oklch(1 0 0); --popover-foreground: oklch(0.22 0.015 250); - --primary: oklch(0.52 0.14 155); - --primary-foreground: oklch(0.99 0.01 155); + --primary: oklch(0.55 0.14 255); + --primary-foreground: oklch(0.99 0.01 255); --secondary: oklch(0.96 0.004 240); --secondary-foreground: oklch(0.22 0.015 250); --muted: oklch(0.96 0.004 240); --muted-foreground: oklch(0.5 0.012 250); - --accent: oklch(0.95 0.02 155); - --accent-foreground: oklch(0.28 0.05 155); + --accent: oklch(0.95 0.02 220); + --accent-foreground: oklch(0.25 0.05 220); --destructive: oklch(0.58 0.2 25); --border: oklch(0.91 0.005 250); --input: oklch(0.97 0.003 240); - --ring: oklch(0.52 0.14 155); - --chart-1: oklch(0.52 0.14 155); + --ring: oklch(0.55 0.14 255); + --chart-1: oklch(0.55 0.14 255); --chart-2: oklch(0.55 0.13 200); - --chart-3: oklch(0.6 0.13 120); - --chart-4: oklch(0.65 0.13 60); + --chart-3: oklch(0.65 0.13 120); + --chart-4: oklch(0.7 0.12 60); --chart-5: oklch(0.55 0.15 300); --sidebar: oklch(0.99 0.002 240); --sidebar-foreground: oklch(0.22 0.015 250); - --sidebar-primary: oklch(0.52 0.14 155); - --sidebar-primary-foreground: oklch(0.99 0.01 155); - --sidebar-accent: oklch(0.95 0.02 155); - --sidebar-accent-foreground: oklch(0.28 0.05 155); + --sidebar-primary: oklch(0.55 0.14 255); + --sidebar-primary-foreground: oklch(0.99 0.01 255); + --sidebar-accent: oklch(0.95 0.02 220); + --sidebar-accent-foreground: oklch(0.25 0.05 220); --sidebar-border: oklch(0.91 0.005 250); - --sidebar-ring: oklch(0.52 0.14 155); + --sidebar-ring: oklch(0.55 0.14 255); /* Accent helper colors */ - --terminal-green: oklch(0.5 0.14 155); + --terminal-green: oklch(0.55 0.14 255); --terminal-cyan: oklch(0.5 0.12 200); --terminal-yellow: oklch(0.58 0.13 75); --terminal-bg: oklch(1 0 0); @@ -151,8 +151,8 @@ --card-foreground: oklch(0.96 0.005 240); --popover: oklch(0.23 0.012 240); --popover-foreground: oklch(0.96 0.005 240); - --primary: oklch(0.72 0.15 155); - --primary-foreground: oklch(0.16 0.02 155); + --primary: oklch(0.6 0.14 255); + --primary-foreground: oklch(0.98 0.01 255); --secondary: oklch(0.28 0.012 240); --secondary-foreground: oklch(0.96 0.005 240); --muted: oklch(0.27 0.012 240); @@ -162,20 +162,20 @@ --destructive: oklch(0.62 0.2 25); --border: oklch(0.31 0.012 240); --input: oklch(0.28 0.012 240); - --ring: oklch(0.72 0.15 155); - --chart-1: oklch(0.72 0.15 155); - --chart-2: oklch(0.6 0.15 200); - --chart-3: oklch(0.7 0.14 120); - --chart-4: oklch(0.75 0.12 60); - --chart-5: oklch(0.6 0.16 300); + --ring: oklch(0.6 0.14 255); + --chart-1: oklch(0.6 0.14 255); + --chart-2: oklch(0.55 0.13 200); + --chart-3: oklch(0.65 0.13 120); + --chart-4: oklch(0.7 0.12 60); + --chart-5: oklch(0.55 0.15 300); --sidebar: oklch(0.21 0.012 240); --sidebar-foreground: oklch(0.96 0.005 240); - --sidebar-primary: oklch(0.72 0.15 155); - --sidebar-primary-foreground: oklch(0.16 0.02 155); + --sidebar-primary: oklch(0.6 0.14 255); + --sidebar-primary-foreground: oklch(0.98 0.01 255); --sidebar-accent: oklch(0.3 0.014 240); --sidebar-accent-foreground: oklch(0.96 0.005 240); --sidebar-border: oklch(0.31 0.012 240); - --sidebar-ring: oklch(0.72 0.15 155); + --sidebar-ring: oklch(0.6 0.14 255); } @layer base { diff --git a/src/application/dto/ai-action.dto.ts b/src/application/dto/ai-action.dto.ts new file mode 100644 index 0000000..62f1e8d --- /dev/null +++ b/src/application/dto/ai-action.dto.ts @@ -0,0 +1,12 @@ +import { Package } from '@/types'; + +export interface ParsedAIAction { + type: 'add' | 'remove'; + packages: Array<{ pkg: Package; versionId?: string }>; +} + +export interface ParsedAIResponse { + text: string; + action: ParsedAIAction | null; + executed: boolean; +} diff --git a/src/application/dto/chat-message.dto.ts b/src/application/dto/chat-message.dto.ts new file mode 100644 index 0000000..61b6599 --- /dev/null +++ b/src/application/dto/chat-message.dto.ts @@ -0,0 +1,10 @@ +import { ChatMessage } from '@/types'; + +export interface ChatWithAIInput { + messages: ChatMessage[]; + bucketContext: string[]; +} + +export interface ChatWithAIOutput { + stream: ReadableStream; +} diff --git a/src/application/dto/fetch-versions.dto.ts b/src/application/dto/fetch-versions.dto.ts new file mode 100644 index 0000000..d19166e --- /dev/null +++ b/src/application/dto/fetch-versions.dto.ts @@ -0,0 +1,7 @@ +export interface FetchVersionsInput { + packageIds: string[]; +} + +export interface FetchVersionsOutput { + versions: Record; +} diff --git a/src/application/dto/generate-script.dto.ts b/src/application/dto/generate-script.dto.ts new file mode 100644 index 0000000..fd82cd9 --- /dev/null +++ b/src/application/dto/generate-script.dto.ts @@ -0,0 +1,11 @@ +import { OS, Package, Shell } from '@/types'; + +export interface GenerateScriptInput { + packages: Package[]; + platform: OS; + shell: Shell; +} + +export interface GenerateScriptOutput { + script: string; +} diff --git a/src/application/dto/share-script.dto.ts b/src/application/dto/share-script.dto.ts new file mode 100644 index 0000000..821978b --- /dev/null +++ b/src/application/dto/share-script.dto.ts @@ -0,0 +1,9 @@ +export interface ShareScriptInput { + script: string; + os?: string; + packages?: string[]; +} + +export interface ShareScriptOutput { + id: string; +} diff --git a/src/application/ports/incoming/chat-with-ai.port.ts b/src/application/ports/incoming/chat-with-ai.port.ts new file mode 100644 index 0000000..a12236f --- /dev/null +++ b/src/application/ports/incoming/chat-with-ai.port.ts @@ -0,0 +1,5 @@ +import { ChatWithAIInput, ChatWithAIOutput } from '@/application/dto/chat-message.dto'; + +export interface ChatWithAIPort { + execute(input: ChatWithAIInput): Promise; +} diff --git a/src/application/ports/incoming/generate-script.port.ts b/src/application/ports/incoming/generate-script.port.ts new file mode 100644 index 0000000..218740c --- /dev/null +++ b/src/application/ports/incoming/generate-script.port.ts @@ -0,0 +1,5 @@ +import { GenerateScriptInput, GenerateScriptOutput } from '@/application/dto/generate-script.dto'; + +export interface GenerateScriptPort { + execute(input: GenerateScriptInput): Promise; +} diff --git a/src/application/ports/incoming/manage-bucket.port.ts b/src/application/ports/incoming/manage-bucket.port.ts new file mode 100644 index 0000000..a5a6021 --- /dev/null +++ b/src/application/ports/incoming/manage-bucket.port.ts @@ -0,0 +1,8 @@ +import { Package } from '@/types'; + +export interface ManageBucketPort { + addPackage(pkg: Package): Promise; + removePackage(packageId: string): Promise; + clear(): Promise; + getContents(): Promise; +} diff --git a/src/application/ports/outgoing/ai-provider.port.ts b/src/application/ports/outgoing/ai-provider.port.ts new file mode 100644 index 0000000..4d82275 --- /dev/null +++ b/src/application/ports/outgoing/ai-provider.port.ts @@ -0,0 +1,5 @@ +import { ChatMessage } from '@/types'; + +export interface AIProvider { + streamChat(messages: ChatMessage[], bucketContext: string[]): Promise>; +} diff --git a/src/application/ports/outgoing/script-share.port.ts b/src/application/ports/outgoing/script-share.port.ts new file mode 100644 index 0000000..591e3b9 --- /dev/null +++ b/src/application/ports/outgoing/script-share.port.ts @@ -0,0 +1,10 @@ +export interface ScriptShareRecord { + script: string; + createdAt: number; + meta: { os: string; packages: string[] }; +} + +export interface ScriptSharePort { + create(entry: Omit): Promise; + findById(id: string): Promise; +} diff --git a/src/application/ports/outgoing/storage.port.ts b/src/application/ports/outgoing/storage.port.ts new file mode 100644 index 0000000..8bdded0 --- /dev/null +++ b/src/application/ports/outgoing/storage.port.ts @@ -0,0 +1,5 @@ +export interface StoragePort { + load(): Promise; + save(value: T): Promise; + clear(): Promise; +} diff --git a/src/application/use-cases/chat-with-ai.use-case.test.ts b/src/application/use-cases/chat-with-ai.use-case.test.ts new file mode 100644 index 0000000..1adde2c --- /dev/null +++ b/src/application/use-cases/chat-with-ai.use-case.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatWithAIUseCase } from './chat-with-ai.use-case'; +import { AIProvider } from '../ports/outgoing/ai-provider.port'; + +describe('ChatWithAIUseCase', () => { + it('delegates streaming chat to the configured provider', async () => { + const stream = new ReadableStream(); + const provider: AIProvider = { + streamChat: vi.fn(async () => stream), + }; + + const result = await new ChatWithAIUseCase(provider).execute({ + messages: [{ role: 'user', content: 'hello' }], + bucketContext: ['Git'], + }); + + expect(result.stream).toBe(stream); + expect(provider.streamChat).toHaveBeenCalledWith( + [{ role: 'user', content: 'hello' }], + ['Git'], + ); + }); + + it('rejects invalid message payloads', async () => { + const provider: AIProvider = { + streamChat: vi.fn(), + }; + + await expect(new ChatWithAIUseCase(provider).execute({ + messages: null as never, + bucketContext: [], + })).rejects.toThrow('Invalid messages'); + }); +}); diff --git a/src/application/use-cases/chat-with-ai.use-case.ts b/src/application/use-cases/chat-with-ai.use-case.ts new file mode 100644 index 0000000..9484aa0 --- /dev/null +++ b/src/application/use-cases/chat-with-ai.use-case.ts @@ -0,0 +1,17 @@ +import { ChatWithAIInput, ChatWithAIOutput } from '../dto/chat-message.dto'; +import { ChatWithAIPort } from '../ports/incoming/chat-with-ai.port'; +import { AIProvider } from '../ports/outgoing/ai-provider.port'; + +export class ChatWithAIUseCase implements ChatWithAIPort { + constructor(private readonly aiProvider: AIProvider) {} + + async execute(input: ChatWithAIInput): Promise { + if (!Array.isArray(input.messages)) { + throw new Error('Invalid messages'); + } + + return { + stream: await this.aiProvider.streamChat(input.messages, input.bucketContext), + }; + } +} diff --git a/src/application/use-cases/fetch-versions.use-case.test.ts b/src/application/use-cases/fetch-versions.use-case.test.ts new file mode 100644 index 0000000..b8c3e03 --- /dev/null +++ b/src/application/use-cases/fetch-versions.use-case.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FetchVersionsUseCase } from './fetch-versions.use-case'; +import { VersionRepository } from '@/domain/repositories/version-repository.interface'; + +describe('FetchVersionsUseCase', () => { + it('returns a version map for package ids', async () => { + const repository: VersionRepository = { + fetchLatest: vi.fn(async (packageId) => [`${packageId}-1.0.0`]), + validateVersion: vi.fn(), + }; + + const result = await new FetchVersionsUseCase(repository).execute({ + packageIds: ['nodejs', 'go'], + }); + + expect(result.versions).toEqual({ + nodejs: ['nodejs-1.0.0'], + go: ['go-1.0.0'], + }); + }); +}); diff --git a/src/application/use-cases/fetch-versions.use-case.ts b/src/application/use-cases/fetch-versions.use-case.ts new file mode 100644 index 0000000..ee37da1 --- /dev/null +++ b/src/application/use-cases/fetch-versions.use-case.ts @@ -0,0 +1,17 @@ +import { VersionRepository } from '@/domain/repositories/version-repository.interface'; +import { FetchVersionsInput, FetchVersionsOutput } from '../dto/fetch-versions.dto'; + +export class FetchVersionsUseCase { + constructor(private readonly versionRepository: VersionRepository) {} + + async execute(input: FetchVersionsInput): Promise { + const entries = await Promise.all( + input.packageIds.map(async (packageId) => [ + packageId, + await this.versionRepository.fetchLatest(packageId), + ] as const), + ); + + return { versions: Object.fromEntries(entries) }; + } +} diff --git a/src/application/use-cases/generate-brewfile.use-case.ts b/src/application/use-cases/generate-brewfile.use-case.ts new file mode 100644 index 0000000..7fadd3a --- /dev/null +++ b/src/application/use-cases/generate-brewfile.use-case.ts @@ -0,0 +1,8 @@ +import { Package } from '@/types'; +import { generateBrewfile } from '@/domain/services/script-generator'; + +export class GenerateBrewfileUseCase { + execute(packages: Package[]): string { + return generateBrewfile(packages); + } +} diff --git a/src/application/use-cases/generate-script.use-case.test.ts b/src/application/use-cases/generate-script.use-case.test.ts new file mode 100644 index 0000000..4a0b633 --- /dev/null +++ b/src/application/use-cases/generate-script.use-case.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { GenerateScriptUseCase } from './generate-script.use-case'; +import { Package } from '@/types'; + +const pkg: Package = { + id: 'git', + name: 'Git', + description: 'Version control', + category: 'vcs', + platforms: { macos: true, linux: true }, + defaultVersion: 'stable', + versions: [ + { + id: 'stable', + label: 'Stable', + macCommand: 'brew install git', + linuxCommand: 'sudo apt-get install -y git', + }, + ], +}; + +describe('GenerateScriptUseCase', () => { + it('generates a script entity payload', async () => { + const result = await new GenerateScriptUseCase().execute({ + platform: 'linux', + shell: 'bash', + packages: [pkg], + }); + + expect(result.script).toContain('SudoStart'); + expect(result.script).toContain('sudo apt-get install -y git'); + }); + + it('supports synchronous execution for browser presentation code', () => { + const result = new GenerateScriptUseCase().executeSync({ + platform: 'macos', + shell: 'zsh', + packages: [pkg], + }); + + expect(result.script).toContain('brew install git'); + }); +}); diff --git a/src/application/use-cases/generate-script.use-case.ts b/src/application/use-cases/generate-script.use-case.ts new file mode 100644 index 0000000..80198f2 --- /dev/null +++ b/src/application/use-cases/generate-script.use-case.ts @@ -0,0 +1,23 @@ +import { Script } from '@/domain/entities/script'; +import { PackageEntity } from '@/domain/entities/package'; +import { Platform } from '@/domain/value-objects/platform'; +import { Shell } from '@/domain/value-objects/shell'; +import { generateScript } from '@/domain/services/script-generator'; +import { GenerateScriptInput, GenerateScriptOutput } from '../dto/generate-script.dto'; +import { GenerateScriptPort } from '../ports/incoming/generate-script.port'; + +export class GenerateScriptUseCase implements GenerateScriptPort { + async execute(input: GenerateScriptInput): Promise { + return this.executeSync(input); + } + + executeSync(input: GenerateScriptInput): GenerateScriptOutput { + const platform = Platform.create(input.platform); + const shell = Shell.create(input.shell); + const packages = input.packages.map((pkg) => PackageEntity.fromDTO(pkg)); + const content = generateScript(input.platform, input.shell, input.packages); + const script = new Script({ content, packages, targetPlatform: platform, shell }); + + return { script: script.toString() }; + } +} diff --git a/src/application/use-cases/get-install-estimates.use-case.ts b/src/application/use-cases/get-install-estimates.use-case.ts new file mode 100644 index 0000000..4371c1d --- /dev/null +++ b/src/application/use-cases/get-install-estimates.use-case.ts @@ -0,0 +1,14 @@ +import { Package } from '@/types'; +import { estimateDiskSpace, estimateInstallTime } from '@/domain/services/script-generator'; + +export class GetInstallEstimatesUseCase { + execute(packages: Package[]): { estimatedMinutes: number; estimatedDiskMb: number; diskLabel: string } { + const estimatedMinutes = estimateInstallTime(packages); + const estimatedDiskMb = estimateDiskSpace(packages); + const diskLabel = estimatedDiskMb >= 1000 + ? `${(estimatedDiskMb / 1000).toFixed(1)} GB` + : `${estimatedDiskMb} MB`; + + return { estimatedMinutes, estimatedDiskMb, diskLabel }; + } +} diff --git a/src/application/use-cases/get-package-versions.use-case.ts b/src/application/use-cases/get-package-versions.use-case.ts new file mode 100644 index 0000000..760a4b6 --- /dev/null +++ b/src/application/use-cases/get-package-versions.use-case.ts @@ -0,0 +1,60 @@ +import { Package } from '@/types'; +import { FetchVersionsUseCase } from './fetch-versions.use-case'; + +const DYNAMIC_VERSION_TOOLS = [ + 'nodejs', 'python3', 'rust', 'go', 'docker', 'nvm', 'ruby', 'php', 'kotlin', 'java', + 'bun', 'deno', 'elixir', 'erlang', 'scala', 'clojure', 'haskell', 'lua', 'perl', 'r', + 'postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'sqlite3', + 'cockroachdb', 'cassandra', 'neo4j', 'clickhouse', 'timescaledb', + 'flutter', 'vscode', 'zed', 'vim', 'neovim', 'emacs', 'antigravity', + 'terraform', 'ansible', 'github-cli', 'git', 'curl', 'zsh', 'oh-my-zsh', 'jq', 'htop', 'tmux', + 'lazygit', 'delta', 'httpie', 'pandoc', + 'podman', 'kubectl', 'minikube', 'lima', 'multipass', 'vagrant', 'packer', 'buildah', 'skopeo', + 'jenkins', 'prometheus', 'docker-compose', 'pulumi', 'helm', 'kustomize', 'argocd-cli', + 'react', 'vue', 'angular', 'nextjs', 'django', 'flask', 'express', + 'nginx', 'apache', 'godot', 'blender', 'electron', 'tauri', 'react-native', + 'zen-browser', 'brave', 'firefox', 'alacritty', 'kitty', 'hyper', + 'jupyter', 'tensorflow', 'pandas', 'numpy', 'matplotlib', + 'aws-cli', 'azure-cli', 'gcloud', 'vercel-cli', 'netlify-cli', 'supabase-cli', 'stripe-cli', 'aws-cdk', + 'bitwarden-cli', '1password-cli', 'gpg', 'openssl', 'wireguard', + 'gimp', 'inkscape', 'krita', 'audacity', 'obs-studio', 'ffmpeg', 'imagemagick', + 'git-lfs', 'github-desktop', 'sublime-merge', 'fork', 'tower', + 'ripgrep', 'fd', 'fzf', 'bat', 'exa', 'dust', 'bottom', 'glances', 'ngrok', 'insomnia', + 'notion', 'obsidian', 'logseq', 'todoist', 'taskwarrior', 'timewarrior', 'calcurse', 'newsboat', +]; + +export interface PackageVersionOption { + id: string; + label: string; +} + +export interface GetPackageVersionsInput { + package: Package; + dynamicVersions?: string[]; +} + +export interface GetPackageVersionsOutput { + supportsDynamic: boolean; + versions: PackageVersionOption[]; +} + +export class GetPackageVersionsUseCase { + constructor(private readonly fetchVersionsUseCase: FetchVersionsUseCase) {} + + execute(input: GetPackageVersionsInput): GetPackageVersionsOutput { + const supportsDynamic = DYNAMIC_VERSION_TOOLS.includes(input.package.id); + const versions = supportsDynamic && input.dynamicVersions && input.dynamicVersions.length > 0 + ? input.dynamicVersions.map((version) => ({ id: version, label: version })) + : input.package.versions.map((version) => ({ id: version.id, label: version.label })); + + return { supportsDynamic, versions }; + } + + async fetchDynamicVersions(pkg: Package): Promise { + if (!DYNAMIC_VERSION_TOOLS.includes(pkg.id)) return []; + + const packageId = pkg.id === 'python3' ? 'python' : pkg.id; + const { versions } = await this.fetchVersionsUseCase.execute({ packageIds: [packageId] }); + return versions[packageId] ?? []; + } +} diff --git a/src/application/use-cases/get-packages-for-platform.use-case.ts b/src/application/use-cases/get-packages-for-platform.use-case.ts new file mode 100644 index 0000000..5712e36 --- /dev/null +++ b/src/application/use-cases/get-packages-for-platform.use-case.ts @@ -0,0 +1,45 @@ +import { Category, OS, Package } from '@/types'; +import { PackageRepository } from '@/domain/repositories/package-repository.interface'; + +export interface GetPackagesForPlatformInput { + platform: OS | null; + category?: Category | 'all'; +} + +export interface GetPackagesForPlatformOutput { + packages: Package[]; + categories: Array; + categoryCounts: Record; +} + +export class GetPackagesForPlatformUseCase { + constructor(private readonly packageRepository: PackageRepository) {} + + async execute(input: GetPackagesForPlatformInput): Promise { + return this.executeSync(input); + } + + executeSync(input: GetPackagesForPlatformInput): GetPackagesForPlatformOutput { + const packages = this.findAll() + .filter((pkg) => !input.platform || pkg.platforms[input.platform]); + const filtered = input.category && input.category !== 'all' + ? packages.filter((pkg) => pkg.category === input.category) + : packages; + const categories = ['all', ...Array.from(new Set(packages.map((pkg) => pkg.category)))] as Array; + const categoryCounts = packages.reduce>((counts, pkg) => { + counts[pkg.category] = (counts[pkg.category] ?? 0) + 1; + return counts; + }, { all: packages.length }); + + return { packages: filtered, categories, categoryCounts }; + } + + private findAll(): Package[] { + const repository = this.packageRepository as PackageRepository & { findAllSync?: () => Package[] }; + if (!repository.findAllSync) { + throw new Error('Package repository does not support synchronous catalog reads'); + } + + return repository.findAllSync(); + } +} diff --git a/src/application/use-cases/get-preview-command.use-case.ts b/src/application/use-cases/get-preview-command.use-case.ts new file mode 100644 index 0000000..ee9e41d --- /dev/null +++ b/src/application/use-cases/get-preview-command.use-case.ts @@ -0,0 +1,34 @@ +import { OS, Package } from '@/types'; + +const GENERIC_VERSIONS = ['stable', 'latest']; + +export interface GetPreviewCommandInput { + package: Package; + platform: OS | null; + version: string; +} + +export class GetPreviewCommandUseCase { + execute(input: GetPreviewCommandInput): string { + const { package: pkg, platform, version } = input; + if (!platform) return ''; + + const versionEntry = pkg.versions.find((candidate) => candidate.id === version); + const template = platform === 'macos' ? pkg.macosCommandTemplate : pkg.linuxCommandTemplate; + const isGeneric = GENERIC_VERSIONS.includes(version); + + if (template && !isGeneric) { + const v = version.startsWith('v') ? version : `v${version}`; + const vNoV = version.startsWith('v') ? version.slice(1) : version; + const vMajor = vNoV.split('.')[0]; + return template + .replaceAll('${VERSION}', v) + .replaceAll('${VERSION_NO_V}', vNoV) + .replaceAll('${VERSION_MAJOR}', vMajor); + } + + return versionEntry + ? (platform === 'macos' ? versionEntry.macCommand : versionEntry.linuxCommand) + : ''; + } +} diff --git a/src/application/use-cases/manage-bucket.use-case.test.ts b/src/application/use-cases/manage-bucket.use-case.test.ts new file mode 100644 index 0000000..f16076a --- /dev/null +++ b/src/application/use-cases/manage-bucket.use-case.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ManageBucketUseCase } from './manage-bucket.use-case'; +import { StoragePort } from '../ports/outgoing/storage.port'; +import { Package } from '@/types'; + +const pkg: Package = { + id: 'git', + name: 'Git', + description: 'Version control', + category: 'vcs', + platforms: { macos: true, linux: true }, + defaultVersion: 'stable', + versions: [ + { + id: 'stable', + label: 'Stable', + macCommand: 'brew install git', + linuxCommand: 'sudo apt-get install -y git', + }, + ], +}; + +function createStorage(initial: Package[] = []): StoragePort & { saved: Package[][] } { + const saved: Package[][] = []; + let value = initial; + return { + saved, + load: vi.fn(async () => value), + save: vi.fn(async (next) => { + saved.push(next); + value = next; + }), + clear: vi.fn(async () => { + value = []; + }), + }; +} + +describe('ManageBucketUseCase', () => { + it('adds, removes, and clears packages through storage', async () => { + const storage = createStorage(); + const useCase = new ManageBucketUseCase(storage); + + expect(await useCase.addPackage(pkg)).toHaveLength(1); + expect(await useCase.removePackage('git')).toHaveLength(0); + expect(await useCase.clear()).toHaveLength(0); + expect(storage.saved).toHaveLength(3); + }); +}); diff --git a/src/application/use-cases/manage-bucket.use-case.ts b/src/application/use-cases/manage-bucket.use-case.ts new file mode 100644 index 0000000..21dd9d0 --- /dev/null +++ b/src/application/use-cases/manage-bucket.use-case.ts @@ -0,0 +1,134 @@ +import { Bucket } from '@/domain/entities/bucket'; +import { PackageEntity } from '@/domain/entities/package'; +import { PackageRepository } from '@/domain/repositories/package-repository.interface'; +import { Package } from '@/types'; +import { ManageBucketPort } from '../ports/incoming/manage-bucket.port'; +import { StoragePort } from '../ports/outgoing/storage.port'; + +export class ManageBucketUseCase implements ManageBucketPort { + constructor( + private readonly storage?: StoragePort, + private readonly packageRepository?: PackageRepository, + ) {} + + async addPackage(pkg: Package): Promise { + const storage = this.getStorage(); + const bucket = await this.loadBucket(); + return this.persist(bucket.add(PackageEntity.fromDTO(pkg)), storage); + } + + async removePackage(packageId: string): Promise { + const storage = this.getStorage(); + const bucket = await this.loadBucket(); + return this.persist(bucket.remove(packageId), storage); + } + + async clear(): Promise { + const storage = this.getStorage(); + const bucket = await this.loadBucket(); + return this.persist(bucket.clear(), storage); + } + + async getContents(): Promise { + return (await this.loadBucket()).getItems().map((pkg) => pkg.toDTO()); + } + + addPackageToBucket(current: Package[], pkg: Package): Package[] { + return this.toPackages(this.createBucket(current).add(PackageEntity.fromDTO(pkg))); + } + + addPackagesToBucket(current: Package[], packages: Package[]): Package[] { + return packages.reduce( + (bucket, pkg) => this.addPackageToBucket(bucket, { + ...pkg, + selectedVersion: pkg.selectedVersion ?? pkg.defaultVersion, + }), + current, + ); + } + + removePackageFromBucket(current: Package[], packageId: string): Package[] { + return this.toPackages(this.createBucket(current).remove(packageId)); + } + + clearBucket(current: Package[]): Package[] { + return this.toPackages(this.createBucket(current).clear()); + } + + updatePackageVersion(current: Package[], packageId: string, version: string): Package[] { + return current.map((pkg) => ( + pkg.id === packageId ? { ...pkg, selectedVersion: version } : pkg + )); + } + + updatePackageNote(current: Package[], packageId: string, note: string): Package[] { + return current.map((pkg) => ( + pkg.id === packageId ? { ...pkg, versionNote: note } : pkg + )); + } + + getDefaultPackages(): Package[] { + const repository = this.getSyncRepository(); + return repository.getDefaultPackagesSync(); + } + + getPackagesByIds(packageIds: string[]): Package[] { + const repository = this.getSyncRepository(); + return packageIds + .map((id) => repository.findByIdSync(id)?.toDTO()) + .filter((pkg): pkg is Package => Boolean(pkg)); + } + + importBucketEntries(entries: Array<{ id: string; selectedVersion?: string; versionNote?: string }>): Package[] { + return entries.flatMap(({ id, selectedVersion, versionNote }) => { + const pkg = this.getSyncRepository().findByIdSync(id)?.toDTO(); + if (!pkg) return []; + return [{ + ...pkg, + selectedVersion: selectedVersion || pkg.defaultVersion, + versionNote: versionNote || '', + }]; + }); + } + + private async loadBucket(): Promise { + const stored = await this.getStorage().load(); + return new Bucket((stored ?? []).map((pkg) => PackageEntity.fromDTO(pkg))); + } + + private async persist(bucket: Bucket, storage: StoragePort): Promise { + const packages = this.toPackages(bucket); + await storage.save(packages); + return packages; + } + + private createBucket(packages: Package[]): Bucket { + return new Bucket(packages.map((pkg) => PackageEntity.fromDTO(pkg))); + } + + private toPackages(bucket: Bucket): Package[] { + return bucket.getItems().map((pkg) => pkg.toDTO()); + } + + private getStorage(): StoragePort { + if (!this.storage) { + throw new Error('ManageBucketUseCase requires a storage port for async persistence operations'); + } + + return this.storage; + } + + private getSyncRepository(): PackageRepository & { + findByIdSync: (id: string) => PackageEntity | null; + getDefaultPackagesSync: () => Package[]; + } { + if (!this.packageRepository) { + throw new Error('ManageBucketUseCase requires a package repository for catalog operations'); + } + + return this.packageRepository as PackageRepository & { + findByIdSync: (id: string) => PackageEntity | null; + getDefaultPackagesSync: () => Package[]; + }; + } +} diff --git a/src/application/use-cases/parse-ai-action.use-case.test.ts b/src/application/use-cases/parse-ai-action.use-case.test.ts new file mode 100644 index 0000000..d1ec692 --- /dev/null +++ b/src/application/use-cases/parse-ai-action.use-case.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { ParseAIActionUseCase } from './parse-ai-action.use-case'; +import { StaticPackageRepository } from '@/infrastructure/adapters/catalog/static-package.repository'; + +describe('ParseAIActionUseCase', () => { + it('extracts validated add actions from AI JSON', async () => { + const result = await new ParseAIActionUseCase(new StaticPackageRepository()).execute( + '{"response":"Added Git","action":{"type":"add","packageIds":["git:stable"]}}', + ); + + expect(result.text).toBe('Added Git'); + expect(result.action?.type).toBe('add'); + expect(result.action?.packages[0].pkg.id).toBe('git'); + expect(result.action?.packages[0].versionId).toBe('stable'); + }); + + it('falls back to plain text when no JSON is present', async () => { + const result = await new ParseAIActionUseCase(new StaticPackageRepository()).execute('hello'); + + expect(result).toEqual({ text: 'hello', action: null, executed: false }); + }); + + it('ignores unsupported actions and invalid package payloads', async () => { + const useCase = new ParseAIActionUseCase(new StaticPackageRepository()); + + await expect(useCase.execute( + '{"response":"Nope","action":{"type":"replace","packageIds":["git"]}}', + )).resolves.toMatchObject({ text: 'Nope', action: null, executed: true }); + + const result = await useCase.execute( + '{"response":"Filtered","action":{"type":"add","packageIds":["not-a-real-package","git:bad;version",42]}}', + ); + + expect(result.action?.packages).toEqual([]); + }); +}); diff --git a/src/application/use-cases/parse-ai-action.use-case.ts b/src/application/use-cases/parse-ai-action.use-case.ts new file mode 100644 index 0000000..353bd4e --- /dev/null +++ b/src/application/use-cases/parse-ai-action.use-case.ts @@ -0,0 +1,57 @@ +import { PackageRepository } from '@/domain/repositories/package-repository.interface'; +import { isValidPackageId, isValidVersion } from '@/lib/security'; +import { ParsedAIResponse } from '../dto/ai-action.dto'; + +export class ParseAIActionUseCase { + constructor(private readonly packageRepository: PackageRepository) {} + + async execute(fullContent: string): Promise { + try { + const jsonMatch = fullContent.match(/\{[\s\S]*\}/); + if (!jsonMatch) return { text: fullContent, action: null, executed: false }; + + const parsed = JSON.parse(jsonMatch[0]); + const text = parsed.response ?? fullContent; + const action = parsed.action; + + if (!action?.packageIds || !Array.isArray(action.packageIds)) { + return { text, action: null, executed: true }; + } + + if (action.type !== 'add' && action.type !== 'remove') { + return { text, action: null, executed: true }; + } + + const packages = []; + for (const idWithVersion of action.packageIds) { + if (typeof idWithVersion !== 'string') continue; + + const [id, versionId] = idWithVersion.split(':'); + if (!isValidPackageId(id)) { + console.warn(`[Security] Rejected invalid package ID from AI: ${id}`); + continue; + } + + if (versionId && !isValidVersion(versionId)) { + console.warn(`[Security] Rejected invalid version ID from AI: ${versionId}`); + continue; + } + + const pkg = await this.packageRepository.findById(id); + if (!pkg) { + console.warn(`[Security] Rejected unknown package ID from AI: ${id}`); + continue; + } + packages.push({ pkg: pkg.toDTO(), versionId }); + } + + return { + text, + action: { type: action.type, packages }, + executed: true, + }; + } catch { + return { text: fullContent, action: null, executed: false }; + } + } +} diff --git a/src/application/use-cases/search-packages.use-case.ts b/src/application/use-cases/search-packages.use-case.ts new file mode 100644 index 0000000..fb52b70 --- /dev/null +++ b/src/application/use-cases/search-packages.use-case.ts @@ -0,0 +1,70 @@ +import { OS, Package } from '@/types'; +import { PackageRepository } from '@/domain/repositories/package-repository.interface'; + +const POPULAR_PACKAGE_IDS = ['git', 'vscode', 'nodejs', 'docker', 'python3', 'rust', 'cursor', 'zsh', 'go', 'bun', 'vim', 'firefox']; + +export interface SearchPackagesInput { + query: string; + platform: OS | null; + limit?: number; +} + +export interface SearchPackagesOutput { + packages: Package[]; + suggestions: Package[]; +} + +export class SearchPackagesUseCase { + constructor(private readonly packageRepository: PackageRepository) {} + + async execute(input: SearchPackagesInput): Promise { + return this.executeSync(input); + } + + executeSync(input: SearchPackagesInput): SearchPackagesOutput { + const packages = this.findAllForPlatform(input.platform); + const query = input.query.trim().toLowerCase(); + const limit = input.limit ?? 12; + const results = query + ? packages + .filter((pkg) => ( + pkg.name.toLowerCase().includes(query) + || pkg.description.toLowerCase().includes(query) + || pkg.category.toLowerCase().includes(query) + || pkg.id.toLowerCase().includes(query) + )) + .slice(0, limit) + : []; + const suggestions = query + ? [] + : packages.filter((pkg) => POPULAR_PACKAGE_IDS.includes(pkg.id)).slice(0, 8); + + return { packages: results, suggestions }; + } + + getRegistryIdsForPlatform(platform: OS | null): string[] { + const registries = ['npm', 'pypi']; + if (platform === 'macos') registries.push('homebrew'); + if (platform === 'linux') registries.push('apt'); + return registries; + } + + mergeExternalResults(localResults: Package[], externalResults: Package[]): Package[] { + const seen = new Set(localResults.map((pkg) => pkg.name.toLowerCase())); + return externalResults.filter((pkg) => { + const name = pkg.name.toLowerCase(); + if (seen.has(name)) return false; + seen.add(name); + return true; + }); + } + + private findAllForPlatform(platform: OS | null): Package[] { + const repository = this.packageRepository as PackageRepository & { findForPlatformSync?: (platform: OS | null) => Package[] }; + if (!repository.findForPlatformSync) { + throw new Error('Package repository does not support platform catalog reads'); + } + + return repository.findForPlatformSync(platform); + } +} diff --git a/src/application/use-cases/share-script.use-case.test.ts b/src/application/use-cases/share-script.use-case.test.ts new file mode 100644 index 0000000..c7ade9a --- /dev/null +++ b/src/application/use-cases/share-script.use-case.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ShareScriptUseCase } from './share-script.use-case'; +import { ScriptSharePort } from '../ports/outgoing/script-share.port'; + +describe('ShareScriptUseCase', () => { + it('stores a script and returns its id', async () => { + const port: ScriptSharePort = { + create: vi.fn(async () => 'abc123def4'), + findById: vi.fn(), + }; + + await expect(new ShareScriptUseCase(port).execute({ + script: '#!/bin/bash', + os: 'linux', + packages: ['Git'], + })).resolves.toEqual({ id: 'abc123def4' }); + }); + + it('rejects invalid scripts', async () => { + const port: ScriptSharePort = { + create: vi.fn(), + findById: vi.fn(), + }; + + await expect(new ShareScriptUseCase(port).execute({ script: '' })).rejects.toThrow('Invalid script'); + }); +}); diff --git a/src/application/use-cases/share-script.use-case.ts b/src/application/use-cases/share-script.use-case.ts new file mode 100644 index 0000000..61c7f22 --- /dev/null +++ b/src/application/use-cases/share-script.use-case.ts @@ -0,0 +1,26 @@ +import { ShareScriptInput, ShareScriptOutput } from '../dto/share-script.dto'; +import { ScriptSharePort } from '../ports/outgoing/script-share.port'; + +export class ShareScriptUseCase { + constructor(private readonly scriptShare: ScriptSharePort) {} + + async execute(input: ShareScriptInput): Promise { + if (!input.script || typeof input.script !== 'string') { + throw new Error('Invalid script'); + } + + if (input.script.length > 1024 * 1024) { + throw new Error('Script too large (max 1MB)'); + } + + const id = await this.scriptShare.create({ + script: input.script, + meta: { + os: input.os ?? 'unknown', + packages: input.packages ?? [], + }, + }); + + return { id }; + } +} diff --git a/src/components/bucket-modal.tsx b/src/components/bucket-modal.tsx index bf0bc95..6b18ba9 100644 --- a/src/components/bucket-modal.tsx +++ b/src/components/bucket-modal.tsx @@ -7,8 +7,8 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useToast } from '@/hooks/use-toast'; import { useFocusTrap, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { EmptyBucketState } from './empty-state'; -import { appCatalog } from '@/lib/apps'; import { PresetsModal } from './presets-modal'; +import { useClientUseCases } from '@/presentation/hooks/use-client-use-cases'; interface BucketModalProps { onClose: () => void; @@ -17,6 +17,7 @@ interface BucketModalProps { export function BucketModal({ onClose }: BucketModalProps) { const { bucket, removeFromBucket, clearBucket, setCurrentStep, updatePackageNote, addDefaultAppsToBucket, addToBucket } = useStore(); const { toast } = useToast(); + const useCases = useClientUseCases(); const modalRef = useRef(null); const [focusedIndex, setFocusedIndex] = useState(-1); const itemRefs = useRef<(HTMLLIElement | null)[]>([]); @@ -25,11 +26,8 @@ export function BucketModal({ onClose }: BucketModalProps) { // Popular packages for quick-add const popularPackages = useMemo(() => { const popularIds = ['git', 'nodejs', 'docker', 'vscode', 'zsh']; - return popularIds - .map(id => appCatalog.find(p => p.id === id)) - .filter((p): p is NonNullable => p !== undefined) - .slice(0, 3); - }, []); + return useCases.manageBucketUseCase.getPackagesByIds(popularIds).slice(0, 3); + }, [useCases]); const handleAddDefaults = useCallback(() => { addDefaultAppsToBucket(); @@ -289,4 +287,4 @@ export function BucketModal({ onClose }: BucketModalProps) { )} ); -} \ No newline at end of file +} diff --git a/src/components/chat-window.tsx b/src/components/chat-window.tsx index 61f82c7..6368a38 100644 --- a/src/components/chat-window.tsx +++ b/src/components/chat-window.tsx @@ -1,350 +1 @@ -'use client'; - -import { useStore } from '@/lib/store'; -import { appCatalog } from '@/lib/apps'; -import { isValidPackageId, isValidVersion } from '@/lib/security'; -import { ChatMessage } from '@/types'; -import { Send, X, Minimize2, Maximize2, Bot } from 'lucide-react'; -import { useState, useRef, useEffect, useCallback } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { useToast } from '@/hooks/use-toast'; -import { useFocusTrap, useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; - -export function ChatWindow() { - const { isChatOpen, toggleChat, addToBucket, removeFromBucket, bucket, updatePackageVersion } = useStore(); - const { toast } = useToast(); - const [messages, setMessages] = useState([ - { - role: 'assistant', - content: "Hello! I'm Root 🌳 I can see your current bucket and help you set up your development environment. What are you building?", - }, - ]); - const [input, setInput] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [streamingContent, setStreamingContent] = useState(''); - const [isMinimized, setIsMinimized] = useState(true); - const textareaRef = useRef(null); - const messagesEndRef = useRef(null); - const chatWindowRef = useRef(null); - const abortRef = useRef(null); - const hasShownMinimizeToast = useRef(false); - - // Focus trap when chat is open and not minimized - useFocusTrap(chatWindowRef, isChatOpen && !isMinimized); - - const handleMinimize = () => { - setIsMinimized(true); - if (!hasShownMinimizeToast.current) { - toast.info('💬 Chat minimized'); - hasShownMinimizeToast.current = true; - } - }; - - const handleMaximize = () => { - setIsMinimized(false); - hasShownMinimizeToast.current = false; - }; - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - if (!isMinimized) scrollToBottom(); - }, [messages, streamingContent, isMinimized]); - - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 128)}px`; - } - }, [input]); - - const parseAndExecuteAction = useCallback( - (fullContent: string) => { - try { - const jsonMatch = fullContent.match(/\{[\s\S]*\}/); - if (!jsonMatch) return { text: fullContent, executed: false }; - const parsed = JSON.parse(jsonMatch[0]); - const text = parsed.response ?? fullContent; - const action = parsed.action; - - if (action?.packageIds && Array.isArray(action.packageIds)) { - action.packageIds.forEach((idWithVersion: string) => { - // SECURITY: Validate the package ID against allowlist - if (typeof idWithVersion !== 'string') return; - - const [id, versionId] = idWithVersion.split(':'); - - // SECURITY: Strict allowlist validation - only accept valid package IDs from catalog - if (!isValidPackageId(id)) { - console.warn(`[Security] Rejected invalid package ID from AI: ${id}`); - return; - } - - // SECURITY: Validate version ID if provided - if (versionId && !isValidVersion(versionId)) { - console.warn(`[Security] Rejected invalid version ID from AI: ${versionId}`); - return; - } - - const pkg = appCatalog.find((p) => p.id === id); - if (!pkg) return; - - if (action.type === 'add') { - const existing = bucket.find((b) => b.id === pkg.id); - if (!existing) { - addToBucket({ ...pkg, selectedVersion: versionId || pkg.defaultVersion }); - } else if (versionId && existing.selectedVersion !== versionId) { - updatePackageVersion(pkg.id, versionId); - } - } else if (action.type === 'remove') { - removeFromBucket(pkg.id); - } - }); - } - return { text, executed: true }; - } catch { - return { text: fullContent, executed: false }; - } - }, - [bucket, addToBucket, removeFromBucket, updatePackageVersion] - ); - - const handleSend = async () => { - if (!input.trim() || isLoading) return; - - const userMessage: ChatMessage = { role: 'user', content: input }; - setMessages((prev) => [...prev, userMessage]); - setInput(''); - setIsLoading(true); - setStreamingContent(''); - - // Build bucket context for system prompt - const bucketContext = bucket.map((p) => `${p.name}${p.selectedVersion && p.selectedVersion !== p.defaultVersion ? ` (${p.selectedVersion})` : ''}`); - - abortRef.current = new AbortController(); - - try { - const response = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages: [...messages, userMessage], bucketContext }), - signal: abortRef.current.signal, - }); - - if (!response.ok) throw new Error('Request failed'); - if (!response.body) throw new Error('No response body'); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let accumulated = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - try { - const json = JSON.parse(line.slice(6)); - if (json.done) { - // Final parse and action execution - const { text } = parseAndExecuteAction(json.full ?? accumulated); - setMessages((prev) => [...prev, { role: 'assistant', content: text }]); - setStreamingContent(''); - } else { - accumulated += json.delta; - setStreamingContent(accumulated); - } - } catch { - // Skip malformed SSE lines - } - } - } - } catch (error: unknown) { - if ((error as Error).name === 'AbortError') return; - console.error('Chat error:', error); - setMessages((prev) => [ - ...prev, - { - role: 'assistant', - content: 'Sorry, I encountered an error. Check that your GROQ_API_KEY is configured in `.env.local`.', - }, - ]); - } finally { - setIsLoading(false); - setStreamingContent(''); - } - }; - - const getDisplayContent = (raw: string) => { - try { - const match = raw.match(/\{[\s\S]*\}/); - if (match) { - const parsed = JSON.parse(match[0]); - return parsed.response ?? raw; - } - } catch {} - return raw; - }; - - // Chat keyboard shortcuts - const chatShortcuts = [ - { - key: 'Escape', - description: 'Close chat', - action: () => { - // Only close if textarea is not focused or input is empty - if (document.activeElement !== textareaRef.current || !input.trim()) { - toggleChat(); - } - }, - preventDefault: false, - }, - ]; - - useKeyboardShortcuts(chatShortcuts, isChatOpen && !isMinimized); - - if (!isChatOpen) return null; - - return ( -
- {/* Header */} -
-
-
- Root AI - {bucket.length > 0 && ( - - {bucket.length} in bucket - - )} -
-
- - -
-
- - {!isMinimized && ( - <> - {/* Messages */} -
- {messages.map((msg, idx) => ( -
- {msg.role === 'assistant' && ( -
- -
- )} -
- - {msg.content} - -
-
- ))} - - {/* Streaming message */} - {streamingContent && ( -
-
- -
-
- - {getDisplayContent(streamingContent)} - - -
-
- )} - - {isLoading && !streamingContent && ( -
-
- -
-
-
- - - -
-
-
- )} -
-
- - {/* Input */} -
-
-