diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c32aae6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-linux: + runs-on: ubuntu-latest + defaults: + run: + working-directory: bun + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun test + + - name: Build binary + run: bun run build + + - name: Rename binary with version + run: | + VERSION=${GITHUB_REF#refs/tags/} + mv bin/katana bin/katana-linux-amd64-${VERSION} + + - name: Upload release asset + uses: softprops/action-gh-release@v2 + with: + files: bun/bin/katana-linux-amd64-* + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index ac1f3ec..e73894f 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,7 @@ fabric.properties .idea/* *.iml .vagrant/ + +# Claude Code artifacts +.CLAUDE_LAST_SESSION.md +.claude/ diff --git a/bun/.gitignore b/bun/.gitignore new file mode 100644 index 0000000..180b35e --- /dev/null +++ b/bun/.gitignore @@ -0,0 +1,39 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +bin/ +katana +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Claude session files +.CLAUDE_LAST_SESSION.md diff --git a/bun/CLAUDE.md b/bun/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/bun/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/bun/IMPLEMENTATION_PLAN.md b/bun/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6d08b88 --- /dev/null +++ b/bun/IMPLEMENTATION_PLAN.md @@ -0,0 +1,572 @@ +# Katana Bun/TypeScript Implementation Plan + +## Overview + +This document outlines the phased implementation plan for reimplementing Katana in Bun/TypeScript. The implementation lives in the `bun/` directory, coexisting with the Python implementation during development. + +### Coexistence Strategy + +- **Development**: TypeScript code lives in `bun/`, Python remains in repository root +- **Shared**: Both implementations read from the same `modules/` directory +- **State Files**: Both use compatible formats for `installed.yml` and `katana.lock` +- **Transition**: Eventually the compiled Bun binary replaces `katanacli.py` + +### Testing Environment Notes + +Development is on Chromebook Linux where some integrations (systemd, Docker) may not be available. The plan includes mock implementations for testing, with full integration testing deferred to a proper Linux environment. + +--- + +## Phase 1: Project Foundation & Core Types ✅ COMPLETE + +**Goal:** Establish project structure, type definitions, YAML validation, and basic CLI skeleton + +### 1.1 Project Setup +- [x] Initialize Bun project with TypeScript strict mode +- [x] Configure `tsconfig.json`, `bunfig.toml` +- [x] Set up Biome for linting/formatting +- [x] Create directory structure +- [x] Set up Bun test runner + +### 1.2 Type Definitions & Zod Schemas (`src/types/`) +- [x] `Module` schema - YAML structure validation with Zod +- [x] `Plugin` interface and base types +- [x] `Task` types for install/remove/start/stop operations +- [x] `ModuleStatus` enum (not_installed, installed, stopped, running, blocked, unknown) +- [x] `Config` schema matching config.yml structure +- [x] `SSEEvent` types for streaming +- [x] Export inferred TypeScript types from Zod schemas + +### 1.3 YAML Module Loader (`src/core/`) +- [x] `ModuleLoader` - scan `../modules/` directory for YAML files +- [x] YAML parsing with `yaml` package +- [x] Zod schema validation with human-friendly error messages +- [x] Graceful error handling for malformed YAML +- [x] Include source file path and line numbers in errors where possible + +### 1.4 CLI Skeleton (`src/cli/`) +- [x] Main entry point (`src/cli.ts`) +- [x] Command routing using `commander` +- [x] Implement commands: + - `katana list [category]` - List available modules + - `katana status ` - Check module status + - `katana validate ` - Validate YAML syntax and schema +- [x] Stub remaining commands: init, install, remove, start, stop, update + +### 1.5 Tests +- [x] Zod schema tests (valid and invalid module structures) +- [x] YAML parsing tests against existing module files in `../modules/` +- [x] Validation error message tests +- [x] CLI argument parsing tests + +**Testable Milestone:** +```bash +cd bun +bun run src/cli.ts list # Shows parsed module names +bun run src/cli.ts list targets # Filter by category +bun run src/cli.ts validate ../modules/targets/dvwa.yml # Validates OK +bun run src/cli.ts validate bad-module.yml # Shows helpful errors +bun test # All unit tests pass +``` + +--- + +## Phase 2: Configuration & State Management ✅ COMPLETE + +**Goal:** Implement configuration loading, state persistence, and lock mode + +### 2.1 Configuration System (`src/core/config-manager.ts`) ✅ +- [x] Zod schema for configuration (types in `src/types/config.ts`) +- [x] ConfigManager singleton for loading config +- [x] Load config from `/etc/katana/config.yml`, `~/.config/katana/config.yml`, or `./config.yml` +- [x] Default config values with sensible defaults +- [x] `katana init` command - interactive config generation +- [x] Non-interactive mode: `katana init --non-interactive --domain-base=wtf` + +### 2.2 State Persistence (`src/core/state-manager.ts`) ✅ +- [x] Read/write `installed.yml` (backward compatible format) +- [x] Atomic file writes (write to temp file, then rename) +- [x] State files in `~/.local/share/katana/` +- [ ] File locking for concurrent access prevention (deferred to Phase 5) + +### 2.3 Lock Mode (`src/core/state-manager.ts`) ✅ +- [x] Read/write `katana.lock` file +- [x] Support legacy format (newline-separated module list) +- [x] Support new YAML format with metadata (locked_at, locked_by, message) +- [x] Auto-migration from legacy to new format on first write +- [x] Lock state checking functions + +### 2.4 CLI Commands ✅ +- [x] `katana init` - generate config file (interactive + non-interactive) +- [x] `katana lock [--message "..."]` - enable lock mode +- [x] `katana unlock` - disable lock mode +- [x] `katana status ` - shows installed/not_installed status +- [x] `katana list` - respects lock mode (only show installed modules when locked) + +### 2.5 Tests ✅ +- [x] State file read/write tests (37 tests) +- [x] Atomic write tests +- [x] Lock mode behavior tests +- [x] Lock file format migration tests +- [x] Config loading/validation tests (17 tests) +- [x] Init command tests (6 tests) +- [x] List lock mode tests (4 tests) + +**Testable Milestone:** +```bash +bun run src/cli.ts init --non-interactive --path /tmp/test.yml # Creates config +bun run src/cli.ts lock --message "Test" # Creates lock file +bun run src/cli.ts list # Shows locked modules only +bun run src/cli.ts status dvwa # Shows status +bun run src/cli.ts unlock # Removes lock +bun test # 112 tests pass +``` + +--- + +## Phase 3: Plugin Architecture & Mock Plugins ✅ COMPLETE + +**Goal:** Build the plugin system with testable mock implementations + +### 3.1 Plugin System (`src/plugins/`) +- [x] `IPlugin` interface with execute/exists/started methods +- [x] `BasePlugin` abstract class with success/failure/noop helpers +- [x] Plugin registry (`PluginRegistry` singleton) - discover and register plugins by alias +- [x] Plugin parameter validation using Zod schemas (from types/module.ts) + +### 3.2 Core Plugins (with mock mode for testing) + +| Plugin | Real Implementation | Mock Mode | +|--------|---------------------|-----------| +| `Command` | `Bun.spawn()` | Log command, return success | +| `File` | `mkdir -p` | Track created dirs in MockState | +| `Copy` | `Bun.write()` | Track written files in MockState | +| `LineInFile` | Read/modify/write file | In-memory line tracking | +| `Git` | `git clone` via spawn | Track repos in MockState | +| `GetUrl` | `fetch()` download | Track files in MockState | +| `Unarchive` | `tar -xzf` via spawn | Create directory in MockState | +| `Replace` | Regex file modification | Log and succeed | +| `Rm` | `rm -rf` via spawn | Remove from MockState | +| `Desktop` | Write .desktop files | Track files in MockState | + +### 3.3 Docker Plugin +- [x] Real: Use `Bun.spawn('docker', [...])` for Docker CLI +- [x] Mock: Track container states in MockState +- [x] Actions: run, start, stop, rm (inferred from operation context) +- [x] Status methods: exists, started + +### 3.4 Service Plugin +- [x] Real: `systemctl` commands via spawn +- [x] Mock: Track service states in MockState +- [x] Methods: start, stop, restart +- [x] Handle `state: running` vs `state: stopped` vs `state: restarted` + +### 3.5 ReverseProxy Plugin +- [x] Create nginx config in /etc/nginx/sites-available +- [x] Symlink to sites-enabled +- [x] Mock: Track configs in MockState + +### 3.6 Task Executor (`src/core/executor.ts`) +- [x] `TaskExecutor` class - execute task lists from module YAML +- [x] Find plugin by task key (docker, service, lineinfile, etc.) +- [x] Sequential task execution with error handling +- [x] EventEmitter for progress events (task:start, task:complete, task:error) +- [x] Operation context passed to plugins (install/remove/start/stop) +- [x] Configurable stopOnError behavior + +### 3.7 Mock State (`src/core/mock-state.ts`) +- [x] `MockState` singleton for in-memory state during testing +- [x] Track containers, services, files, lines, reverse proxies, git repos +- [x] `KATANA_MOCK=true` environment variable enables mock mode +- [x] `isMockMode()` helper function + +### 3.8 CLI Integration +- [x] `install`, `remove`, `start`, `stop` commands use TaskExecutor +- [x] `--dry-run` option for testing without making changes +- [x] Progress output showing task status +- [x] Lock mode prevents install/remove (but allows start/stop) +- [x] State updated on successful install/remove + +### 3.9 Tests +- [x] MockState unit tests (34 tests) +- [x] PluginRegistry unit tests (9 tests) +- [x] DockerPlugin mock mode tests (16 tests) +- [x] TaskExecutor tests with mock plugins (18 tests) +- [x] CLI module operation tests (5 tests) + +**Testable Milestone:** +```bash +KATANA_MOCK=true bun run src/cli.ts install dvwa # Executes tasks with mocks +bun run src/cli.ts status dvwa # Shows "installed" +KATANA_MOCK=true bun run src/cli.ts start dvwa # Starts container (mock) +KATANA_MOCK=true bun run src/cli.ts remove dvwa # Removes module +bun test # 200 tests pass +``` + +--- + +## Phase 4: Dependency Resolution & Status Checking ✅ COMPLETE + +**Goal:** Implement dependency graph and real status checks + +### 4.1 Dependency Resolution (`src/core/dependencies.ts`) ✅ +- [x] Build dependency graph from all modules (`depends-on` field) +- [x] Circular dependency detection with clear error messages +- [x] Topological sort for installation order (Kahn's algorithm) +- [x] Resolve and install dependencies before target module + +### 4.2 Status Checking (`src/core/status.ts`) ✅ +- [x] Parse `status.running.started` checks from module YAML +- [x] Parse `status.installed.exists` checks +- [x] Execute status checks via existing plugin exists/started methods +- [x] Status hierarchy: running > stopped/installed > not_installed +- [x] Status caching with configurable TTL (default 5 seconds) + +### 4.3-4.4 Status Check Execution ✅ +Note: Instead of creating separate exists.ts/started.ts plugins, we leverage the existing plugin `exists()` and `started()` methods (docker, service, file plugins). + +### 4.5 Enhanced CLI Commands ✅ +- [x] `katana status ` - real status checks via StatusChecker +- [x] `katana list --status` - parallel status checks, show status column +- [x] `katana install ` - resolve and install dependencies first (fail-fast) +- [x] `katana remove ` - warn if other modules depend on it + +### 4.6 Tests ✅ +- [x] Dependency graph construction tests (28 tests in dependencies.test.ts) +- [x] Circular dependency detection tests +- [x] Topological sort tests +- [x] Status check logic tests with mocks (15 tests in status.test.ts) +- [x] CLI test updated for new status format + +**Testable Milestone:** +```bash +bun run src/cli.ts list --status # Shows status for all modules +bun run src/cli.ts status dvwa # Real status check (if Docker available) +bun run src/cli.ts install dojo-basic # Installs dependencies first +bun test # 243 tests pass +``` + +--- + +## Phase 5: Web Server & REST API + +**Goal:** Implement HTTP server with REST endpoints and SSE streaming + +### 5.1 HTTP Server Setup (`src/server/`) +- [ ] Hono framework setup with Bun.serve +- [ ] Static file serving for UI assets (from `src/ui/dist/` or `../html/`) +- [ ] CORS configuration for development +- [ ] Error handling middleware +- [ ] Request logging with pino + +### 5.2 REST API Endpoints + +``` +GET /api/modules → List all modules with status +GET /api/modules/:category → List by category (targets, tools, base) +GET /api/modules/:name → Single module details +POST /api/modules/:name/install → Install module (returns operationId) +POST /api/modules/:name/remove → Remove module +POST /api/modules/:name/start → Start module +POST /api/modules/:name/stop → Stop module +GET /api/modules/:name/status → Get module status + +GET /api/config → Get current configuration +GET /api/lock → Get lock status +POST /api/lock → Enable lock mode +DELETE /api/lock → Disable lock mode + +GET /health → Health check +``` + +### 5.3 Operation Queue (`src/server/operations.ts`) +- [ ] Generate operation IDs (crypto.randomUUID) +- [ ] Track running operations in memory +- [ ] Prevent concurrent operations on same module +- [ ] Configurable max concurrent operations (default: 3) +- [ ] Operation timeout handling + +### 5.4 SSE Streaming (`src/server/sse.ts`) +- [ ] `GET /api/operations/:operationId/stream` endpoint +- [ ] Stream progress events during install/remove/start/stop +- [ ] Event types: progress, log, status, complete, error +- [ ] Connection cleanup on client disconnect +- [ ] Heartbeat to keep connection alive + +### 5.5 Lock Mode Integration +- [ ] API respects lock state +- [ ] Return 403 Forbidden for install/remove when locked +- [ ] Include lock status and message in responses +- [ ] Lock indicator in module list response + +### 5.6 Tests +- [ ] API endpoint tests with Bun test + fetch +- [ ] SSE streaming tests +- [ ] Concurrent operation rejection tests +- [ ] Lock mode API tests +- [ ] Error response format tests + +**Testable Milestone:** +```bash +bun run src/cli.ts serve & # Start server on :8087 +curl http://localhost:8087/health +curl http://localhost:8087/api/modules +curl http://localhost:8087/api/modules/dvwa +curl -X POST http://localhost:8087/api/modules/dvwa/install +# Stream in another terminal: +curl -N http://localhost:8087/api/operations/{id}/stream +bun test +``` + +--- + +## Phase 6: Web UI & Production Polish + +**Goal:** Modern React UI with real-time updates and production-ready binary + +### 6.1 Frontend Setup (`src/ui/`) +- [ ] Vite + React + TypeScript project +- [ ] shadcn/ui installation and configuration +- [ ] TailwindCSS setup +- [ ] Build output to `dist/` for embedding in binary + +### 6.2 UI Components +- [ ] Module list view grouped by category (accordion or tabs) +- [ ] Module card with status indicator (colored badge) +- [ ] Action buttons based on state (Install/Remove/Start/Stop/Open) +- [ ] "Open" button for modules with `href` (disabled when not running) +- [ ] Lock mode indicator (banner with message) +- [ ] Base domain display in header/footer + +### 6.3 Real-time Updates +- [ ] SSE client hook for operation progress +- [ ] Live log streaming in modal/drawer +- [ ] Progress bar for multi-step operations +- [ ] Auto-refresh status on operation complete +- [ ] Error display with details + +### 6.4 Remaining Plugins +- [ ] `ReverseProxy` - nginx config generation, SSL cert creation with openssl +- [ ] `GetUrl` - HTTP downloads with fetch, progress tracking +- [ ] `Unarchive` - tar/zip extraction via CLI +- [ ] `DesktopIntegration` - skip gracefully in headless environments +- [ ] `Replace` - file content replacement +- [ ] `Remove` - file/directory removal +- [ ] `Yarn` - yarn command execution (for tools like dojo) + +### 6.5 Production Build +- [ ] Binary compilation: `bun build --compile` +- [ ] Embed static UI assets in binary +- [ ] Structured logging with pino (JSON format) +- [ ] Log file configuration and rotation +- [ ] Graceful shutdown (SIGTERM/SIGINT handling) +- [ ] `--version` flag + +### 6.6 CLI Polish +- [ ] Colored output (chalk or similar) +- [ ] Progress spinners for long operations +- [ ] `--json` flag for machine-readable output +- [ ] `--quiet` / `--verbose` flags + +### 6.7 Tests +- [ ] UI component tests with Vitest +- [ ] E2E tests with real modules (deferred to full Linux environment) + +**Testable Milestone:** +```bash +bun run build # Compiles to single binary +./katana --version # Shows version +./katana serve & # Binary works standalone +# Open http://localhost:8087 in browser - full UI works +# Install a module via UI, watch progress stream +``` + +--- + +## Testing Strategy + +### Chromebook Linux (Development Environment) +- All unit tests with mock plugins +- API integration tests with mocks +- UI development and component tests +- YAML validation against real module files +- No real Docker/systemd integration + +### Full Linux Environment (CI/Production Testing) +- Integration tests with real Docker daemon +- systemd service management tests +- nginx configuration and SSL certificate tests +- E2E tests installing actual modules (dvwa, juice-shop) +- Lock mode with real file permissions + +--- + +## Plugin Implementation Priority + +Based on analysis of existing module YAML files: + +| Priority | Plugin | Used By | Notes | +|----------|--------|---------|-------| +| P0 | Docker | All targets | Core functionality | +| P0 | Service | All targets | systemctl control | +| P0 | LineInFile | All targets | /etc/hosts management | +| P0 | ReverseProxy | All targets | nginx + SSL | +| P1 | Command | Several tools | Shell execution | +| P1 | File | Directory creation | Simple fs.mkdir | +| P1 | Copy | Config files | File writing | +| P1 | Git | Some tools | Repository cloning | +| P1 | Exists | Status checks | Resource existence | +| P1 | Started | Status checks | Running state | +| P2 | GetUrl | Downloads | HTTP fetch | +| P2 | Unarchive | Extractions | tar/zip | +| P2 | Replace | Config edits | String replacement | +| P2 | Remove | Cleanup | File deletion | +| P3 | DesktopIntegration | Desktop shortcuts | Skip in headless | +| P3 | Yarn | Node.js tools | Package management | + +--- + +## Directory Structure + +``` +katana/ +├── bun/ # TypeScript implementation +│ ├── src/ +│ │ ├── cli.ts # CLI entry point +│ │ ├── cli/ +│ │ │ ├── index.ts # Command setup +│ │ │ └── commands/ +│ │ │ ├── init.ts +│ │ │ ├── install.ts +│ │ │ ├── remove.ts +│ │ │ ├── start.ts +│ │ │ ├── stop.ts +│ │ │ ├── status.ts +│ │ │ ├── list.ts +│ │ │ ├── lock.ts +│ │ │ ├── validate.ts +│ │ │ └── serve.ts +│ │ ├── core/ +│ │ │ ├── config.ts # Configuration management +│ │ │ ├── module-loader.ts # YAML discovery and parsing +│ │ │ ├── state.ts # installed.yml management +│ │ │ ├── lock.ts # Lock mode +│ │ │ ├── dependencies.ts # Dependency resolution +│ │ │ ├── status.ts # Status checking +│ │ │ └── executor.ts # Task execution engine +│ │ ├── plugins/ +│ │ │ ├── base.ts # BasePlugin class +│ │ │ ├── registry.ts # Plugin discovery/registration +│ │ │ ├── docker.ts +│ │ │ ├── service.ts +│ │ │ ├── lineinfile.ts +│ │ │ ├── reverseproxy.ts +│ │ │ ├── command.ts +│ │ │ ├── file.ts +│ │ │ ├── copy.ts +│ │ │ ├── git.ts +│ │ │ ├── geturl.ts +│ │ │ ├── unarchive.ts +│ │ │ ├── replace.ts +│ │ │ ├── remove.ts +│ │ │ ├── exists.ts +│ │ │ └── started.ts +│ │ ├── server/ +│ │ │ ├── index.ts # Hono server setup +│ │ │ ├── routes/ +│ │ │ │ ├── modules.ts +│ │ │ │ ├── operations.ts +│ │ │ │ ├── config.ts +│ │ │ │ └── lock.ts +│ │ │ ├── operations.ts # Operation queue +│ │ │ └── sse.ts # SSE streaming helpers +│ │ ├── types/ +│ │ │ ├── module.ts # Module Zod schema + types +│ │ │ ├── plugin.ts # Plugin interfaces +│ │ │ ├── config.ts # Config Zod schema + types +│ │ │ ├── state.ts # State file types +│ │ │ └── events.ts # SSE event types +│ │ └── utils/ +│ │ ├── logger.ts # Pino logger setup +│ │ ├── shell.ts # Bun.spawn helpers +│ │ └── fs.ts # File system helpers +│ ├── ui/ # React frontend (Phase 6) +│ │ ├── src/ +│ │ │ ├── App.tsx +│ │ │ ├── components/ +│ │ │ └── hooks/ +│ │ ├── index.html +│ │ ├── vite.config.ts +│ │ ├── tailwind.config.js +│ │ └── package.json +│ ├── tests/ +│ │ ├── unit/ +│ │ │ ├── types/ +│ │ │ ├── core/ +│ │ │ └── plugins/ +│ │ ├── integration/ +│ │ └── fixtures/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── bunfig.toml +│ ├── biome.json +│ └── IMPLEMENTATION_PLAN.md # This file +│ +├── modules/ # Shared: existing YAML modules +│ ├── targets/ +│ ├── tools/ +│ └── management/ +├── plugins/ # Python plugins (reference) +├── katanacli.py # Python CLI (coexists during dev) +└── ... # Other Python files +``` + +--- + +## Dependencies + +```json +{ + "dependencies": { + "hono": "latest", + "yaml": "latest", + "zod": "latest", + "commander": "latest", + "pino": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest", + "@biomejs/biome": "latest" + } +} +``` + +UI dependencies (Phase 6): +- react, react-dom +- vite, @vitejs/plugin-react +- tailwindcss, postcss, autoprefixer +- shadcn/ui components (copy-paste, not a package) + +--- + +## Success Criteria + +The Bun/TypeScript implementation is complete when: + +1. **Feature Parity**: All existing modules install/run without YAML modifications +2. **Performance**: Web operations complete without timeouts +3. **User Experience**: Real-time progress feedback via SSE +4. **Reliability**: Idempotent operations, proper error handling +5. **Validation**: `katana validate` catches common YAML errors with helpful messages +6. **Compatibility**: Reads existing `installed.yml` and `katana.lock` files +7. **Distribution**: Single binary with embedded UI, no runtime dependencies + +--- + +## Next Steps + +1. **Phase 1.1**: Initialize project in `bun/` directory +2. **Phase 1.2**: Define Zod schemas for module YAML +3. **Phase 1.3**: Implement module loader, test against `../modules/*.yml` +4. **Phase 1.4**: Build CLI with list, status, and validate commands diff --git a/bun/biome.json b/bun/biome.json new file mode 100644 index 0000000..ed67c13 --- /dev/null +++ b/bun/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "lineWidth": 100 + }, + "assist": { + "actions": { + "source": { + "organizeImports": { + "level": "on" + } + } + } + } +} diff --git a/bun/bun.lock b/bun/bun.lock new file mode 100644 index 0000000..970a0e3 --- /dev/null +++ b/bun/bun.lock @@ -0,0 +1,115 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun", + "dependencies": { + "commander": "latest", + "hono": "latest", + "pino": "latest", + "pino-pretty": "^13.1.3", + "yaml": "latest", + "zod": "latest", + }, + "devDependencies": { + "@biomejs/biome": "latest", + "@types/bun": "latest", + "typescript": "latest", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.3.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.10", "@biomejs/cli-darwin-x64": "2.3.10", "@biomejs/cli-linux-arm64": "2.3.10", "@biomejs/cli-linux-arm64-musl": "2.3.10", "@biomejs/cli-linux-x64": "2.3.10", "@biomejs/cli-linux-x64-musl": "2.3.10", "@biomejs/cli-win32-arm64": "2.3.10", "@biomejs/cli-win32-x64": "2.3.10" }, "bin": { "biome": "bin/biome" } }, "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + + "pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + } +} diff --git a/bun/bunfig.toml b/bun/bunfig.toml new file mode 100644 index 0000000..8374782 --- /dev/null +++ b/bun/bunfig.toml @@ -0,0 +1,2 @@ +[test] +coverage = false diff --git a/bun/package.json b/bun/package.json new file mode 100644 index 0000000..9957992 --- /dev/null +++ b/bun/package.json @@ -0,0 +1,26 @@ +{ + "name": "katana", + "version": "0.1.0", + "type": "module", + "module": "src/cli.ts", + "scripts": { + "dev": "bun run src/cli.ts", + "test": "bun test", + "lint": "biome check src/", + "format": "biome format --write src/", + "build": "mkdir -p bin && bun build --compile src/cli.ts --outfile bin/katana" + }, + "dependencies": { + "commander": "latest", + "hono": "latest", + "pino": "latest", + "pino-pretty": "^13.1.3", + "yaml": "latest", + "zod": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "latest", + "@biomejs/biome": "latest" + } +} diff --git a/bun/src/cli.ts b/bun/src/cli.ts new file mode 100644 index 0000000..0cc4938 --- /dev/null +++ b/bun/src/cli.ts @@ -0,0 +1,1223 @@ +#!/usr/bin/env bun +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { Command } from "commander"; +import { stringify as yamlStringify } from "yaml"; +import { CertManager } from "./core/cert-manager"; +import { DependencyResolver } from "./core/dependencies"; +import { allSucceeded, getChanges, getFailures, TaskExecutor } from "./core/executor"; +import { ConfigManager } from "./core/config-manager"; +import { ModuleFetcher } from "./core/module-fetcher"; +import { + formatModuleLoadError, + formatModuleLoaderErrors, + loadAllModules, + loadModule, + ModuleLoader, + ModulesNotFoundError, + validateModuleFile, +} from "./core/module-loader"; +import { StateManager } from "./core/state-manager"; +import { StatusChecker } from "./core/status"; +import { getPluginRegistry } from "./plugins/registry"; +import { createServer, printServerInfo } from "./server"; +import type { ModuleCategory, Operation, Task } from "./types"; +import { ConfigSchema, DEFAULT_CONFIG } from "./types/config"; + +const program = new Command(); + +program.name("katana").version("0.1.0").description("Module deployment and management CLI"); + +// ============================================================================= +// Error Handling Helpers +// ============================================================================= + +/** + * Handle ModulesNotFoundError with helpful guidance + */ +function handleModulesNotFoundError(error: unknown): boolean { + if (error instanceof ModulesNotFoundError) { + console.error(""); + console.error("Modules not found."); + console.error(""); + console.error("To fix this, you can:"); + console.error(" 1. Run 'katana update' to fetch modules from GitHub"); + console.error(" 2. Run 'katana init' to set up configuration"); + console.error(" 3. Set KATANA_HOME environment variable to your katana directory"); + console.error(""); + return true; + } + return false; +} + +// ============================================================================= +// Implemented Commands +// ============================================================================= + +program + .command("list") + .description("List available modules") + .argument("[category]", "Filter by category (targets, tools, network, system)") + .option("--status", "Show real-time status for each module") + .action(async (category?: string, options?: { status?: boolean }) => { + try { + // Check lock state + const stateManager = StateManager.getInstance(); + const lockState = await stateManager.getLockState(); + + // Show lock banner if locked + if (lockState.locked) { + const lockMsg = lockState.message + ? `System is locked: ${lockState.message}` + : "System is locked"; + console.log(""); + console.log(`[LOCKED] ${lockMsg}`); + } + + const loaderOptions = category ? { category: category as ModuleCategory } : {}; + const result = await loadAllModules(loaderOptions); + + if (!result.success && result.modules.length === 0) { + console.error(formatModuleLoaderErrors(result)); + process.exit(1); + } + + // Filter to locked modules if in lock mode + let modules = result.modules; + if (lockState.locked) { + const lockedNames = new Set(lockState.modules.map((m) => m.toLowerCase())); + modules = modules.filter((m) => lockedNames.has(m.name.toLowerCase())); + } + + if (modules.length === 0) { + if (lockState.locked) { + console.log(""); + console.log("No installed modules" + (category ? ` in category: ${category}` : "")); + } else { + console.log(category ? `No modules found in category: ${category}` : "No modules found"); + } + return; + } + + // Get status for all modules if --status flag is set + let statusMap: Map | null = null; + if (options?.status) { + const statusChecker = new StatusChecker(); + statusMap = await statusChecker.checkStatusBatch(modules); + } + + // Print header (with STATUS column if checking status) + console.log(""); + if (statusMap) { + console.log( + `${"NAME".padEnd(25)} ${"CATEGORY".padEnd(12)} ${"STATUS".padEnd(20)} DESCRIPTION`, + ); + console.log(`${"-".repeat(25)} ${"-".repeat(12)} ${"-".repeat(20)} ${"-".repeat(30)}`); + } else { + console.log(`${"NAME".padEnd(25)} ${"CATEGORY".padEnd(12)} DESCRIPTION`); + console.log(`${"-".repeat(25)} ${"-".repeat(12)} ${"-".repeat(40)}`); + } + + // Sort and print modules + const sorted = modules.sort((a, b) => a.name.localeCompare(b.name)); + for (const mod of sorted) { + const desc = mod.description?.slice(0, statusMap ? 30 : 50) || ""; + if (statusMap) { + const statusResult = statusMap.get(mod.name.toLowerCase()); + const statusStr = statusResult ? StatusChecker.formatStatus(statusResult) : "unknown"; + console.log( + `${mod.name.padEnd(25)} ${mod.category.padEnd(12)} ${statusStr.padEnd(20)} ${desc}`, + ); + } else { + console.log(`${mod.name.padEnd(25)} ${mod.category.padEnd(12)} ${desc}`); + } + } + + console.log(""); + if (lockState.locked) { + console.log(`Total: ${modules.length} installed module(s)`); + } else { + console.log(`Total: ${modules.length} module(s)`); + } + + // Show errors if any modules failed to load + if (result.errors.length > 0) { + console.error(""); + console.error(`Warning: ${result.errors.length} module(s) failed to load`); + } + } catch (error) { + if (handleModulesNotFoundError(error)) { + process.exit(1); + } + console.error("Error loading modules:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program + .command("validate") + .description("Validate a module YAML file") + .argument("", "Path to the module YAML file") + .action(async (file: string) => { + try { + const filePath = resolve(file); + const result = await validateModuleFile(filePath); + + if (result.success && result.module) { + console.log(`Valid: ${result.module.name} (${result.module.category})`); + if (result.module.description) { + console.log(` ${result.module.description}`); + } + } else if (result.error) { + console.error(formatModuleLoadError(result.error)); + process.exit(1); + } + } catch (error) { + console.error("Error validating file:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program + .command("status") + .description("Check module status") + .argument("", "Module name") + .action(async (moduleName: string) => { + try { + const result = await loadModule(moduleName); + + if (result.success && result.module) { + console.log(`Module: ${result.module.name}`); + console.log(`Category: ${result.module.category}`); + if (result.module.description) { + console.log(`Description: ${result.module.description}`); + } + console.log(""); + + // Use StatusChecker for real status if module has status checks + if (result.module.status?.installed || result.module.status?.running) { + const statusChecker = new StatusChecker(); + const statusResult = await statusChecker.checkStatus(result.module); + console.log(`Status: ${StatusChecker.formatStatus(statusResult)}`); + } else { + // Fall back to state manager for modules without status checks + const stateManager = StateManager.getInstance(); + const status = await stateManager.getModuleStatus(moduleName); + console.log(`Status: ${status}`); + } + + // Show dependencies if present + const deps = result.module["depends-on"]; + if (deps && deps.length > 0) { + console.log(`Dependencies: ${deps.join(", ")}`); + } + + // Show installation info if installed + const stateManager = StateManager.getInstance(); + const installInfo = await stateManager.getModuleInstallInfo(moduleName); + if (installInfo?.installedAt) { + console.log(`Installed: ${installInfo.installedAt}`); + } + } else if (result.error) { + console.error(formatModuleLoadError(result.error)); + process.exit(1); + } + } catch (error) { + if (handleModulesNotFoundError(error)) { + process.exit(1); + } + console.error("Error checking status:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Init Command +// ============================================================================= + +interface InitOptions { + user?: boolean; + path?: string; + nonInteractive?: boolean; + domainBase?: string; + port?: number; + modulesPath?: string; + modulesBranch?: string; + fetchModules?: boolean; + force?: boolean; +} + +/** + * Expand ~ to home directory in path + */ +function expandPath(path: string): string { + if (path.startsWith("~/")) { + return path.replace("~", homedir()); + } + return path; +} + +/** + * Prompt user for input with a default value + */ +async function promptWithDefault( + rl: ReturnType, + message: string, + defaultValue: string, +): Promise { + const answer = await rl.question(`${message} [${defaultValue}]: `); + return answer.trim() || defaultValue; +} + +/** + * Prompt user for yes/no confirmation + */ +async function promptConfirm( + rl: ReturnType, + message: string, + defaultValue = false, +): Promise { + const defaultStr = defaultValue ? "Y/n" : "y/N"; + const answer = await rl.question(`${message} [${defaultStr}]: `); + const trimmed = answer.trim().toLowerCase(); + if (trimmed === "") return defaultValue; + return trimmed === "y" || trimmed === "yes"; +} + +program + .command("init") + .description("Initialize katana configuration") + .option("--user", "Write to user config (~/.config/katana/config.yml)") + .option("--path ", "Custom output path for config file") + .option("--non-interactive", "Skip interactive prompts, use defaults or provided values") + .option("--domain-base ", "Base domain for module URLs (e.g., 'test' -> dvwa.test)") + .option("--port ", "Server port", Number.parseInt) + .option("--modules-path ", "Path to modules directory") + .option("--modules-branch ", "Git branch for module updates (default: main)") + .option("--fetch-modules", "Fetch modules from GitHub after creating config") + .option("--force", "Overwrite existing config file without prompting") + .action(async (options: InitOptions) => { + // Import ModuleFetcher here to avoid circular dependency issues + const { ModuleFetcher } = await import("./core/module-fetcher"); + + try { + // Determine output path + let outputPath: string; + if (options.path) { + outputPath = expandPath(options.path); + } else if (options.user) { + outputPath = expandPath("~/.config/katana/config.yml"); + } else { + outputPath = "/etc/katana/config.yml"; + } + + // Check if file exists + const file = Bun.file(outputPath); + const exists = await file.exists(); + + let domainBase = options.domainBase ?? DEFAULT_CONFIG.domainBase; + let port = options.port ?? DEFAULT_CONFIG.server.port; + let modulesPath = options.modulesPath; // Now optional, undefined means auto-resolve + let modulesBranch = options.modulesBranch ?? DEFAULT_CONFIG.modulesBranch; + let fetchModules = options.fetchModules ?? false; + let shouldWrite = true; + + if (options.nonInteractive) { + // Non-interactive mode + if (exists && !options.force) { + console.error(`Error: Config file already exists at ${outputPath}`); + console.error("Use --force to overwrite."); + process.exit(1); + } + } else { + // Interactive mode + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log(""); + console.log("Katana Configuration Setup"); + console.log("=========================="); + console.log(""); + + // Check if file exists and prompt to overwrite + if (exists && !options.force) { + const overwrite = await promptConfirm( + rl, + `Config file already exists at ${outputPath}. Overwrite?`, + false, + ); + if (!overwrite) { + console.log("Aborted."); + shouldWrite = false; + } + } + + if (shouldWrite) { + // Prompt for values (use provided options as defaults if given) + domainBase = await promptWithDefault( + rl, + "Base domain for module URLs", + options.domainBase ?? DEFAULT_CONFIG.domainBase, + ); + + const portStr = await promptWithDefault( + rl, + "Server port", + String(options.port ?? DEFAULT_CONFIG.server.port), + ); + port = Number.parseInt(portStr, 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + console.error("Invalid port number. Using default."); + port = DEFAULT_CONFIG.server.port; + } + + modulesBranch = await promptWithDefault( + rl, + "Git branch for module updates", + options.modulesBranch ?? DEFAULT_CONFIG.modulesBranch, + ); + + // Only prompt for modulesPath if user wants custom location + const customModulesPath = await promptConfirm( + rl, + "Use custom modules path? (No = auto-detect)", + false, + ); + if (customModulesPath) { + modulesPath = await promptWithDefault( + rl, + "Path to modules directory", + options.modulesPath ?? "~/.local/share/katana/modules", + ); + } + + // Ask about fetching modules + if (!options.fetchModules) { + fetchModules = await promptConfirm(rl, "Fetch modules from GitHub now?", true); + } + + console.log(""); + } + } finally { + rl.close(); + } + } + + if (!shouldWrite) { + return; + } + + // Build config object - only include modulesPath if explicitly set + const config: Record = { + statePath: DEFAULT_CONFIG.statePath, + domainBase, + modulesRepo: DEFAULT_CONFIG.modulesRepo, + modulesBranch, + server: { + port, + host: DEFAULT_CONFIG.server.host, + cors: DEFAULT_CONFIG.server.cors, + }, + log: { + level: DEFAULT_CONFIG.log.level, + format: DEFAULT_CONFIG.log.format, + }, + }; + + // Only add modulesPath if explicitly configured + if (modulesPath) { + config.modulesPath = modulesPath; + } + + // Validate config + const result = ConfigSchema.safeParse(config); + if (!result.success) { + console.error("Error: Invalid configuration values"); + for (const issue of result.error.issues) { + console.error(` ${issue.path.join(".")}: ${issue.message}`); + } + process.exit(1); + } + + // Create parent directories + const parentDir = dirname(outputPath); + const parentFile = Bun.file(parentDir); + if (!(await parentFile.exists())) { + await Bun.$`mkdir -p ${parentDir}`.quiet(); + } + + // Write config file + const yamlContent = yamlStringify(config); + await Bun.write(outputPath, yamlContent); + + console.log(`Configuration saved to ${outputPath}`); + console.log(""); + console.log("Settings:"); + console.log(` Domain base: ${domainBase}`); + console.log(` Server port: ${port}`); + console.log(` Modules branch: ${modulesBranch}`); + if (modulesPath) { + console.log(` Modules path: ${modulesPath}`); + } else { + console.log(` Modules path: (auto-detect)`); + } + + // Fetch modules if requested + if (fetchModules) { + console.log(""); + console.log("Fetching modules..."); + + const fetcher = new ModuleFetcher(); + const fetchResult = await fetcher.fetchModules({ + repo: DEFAULT_CONFIG.modulesRepo, + branch: modulesBranch, + }); + + if (fetchResult.success) { + console.log(fetchResult.isUpdate ? "Modules updated." : "Modules cloned."); + console.log(` Location: ${fetchResult.modulesPath}`); + } else { + console.error(`Failed to fetch modules: ${fetchResult.message}`); + console.error("You can try again later with: katana update"); + } + } + } catch (error) { + console.error("Error initializing config:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Module Operation Commands (install, remove, start, stop) +// ============================================================================= + +interface ModuleOperationOptions { + dryRun?: boolean; +} + +/** + * Execute tasks for a single module (helper for executeModuleOperation) + */ +async function executeModuleTasks( + moduleName: string, + operation: Operation, + options: ModuleOperationOptions, + stateManager: StateManager, +): Promise { + const result = await loadModule(moduleName); + if (!result.success || !result.module) { + if (result.error) { + console.error(formatModuleLoadError(result.error)); + } else { + console.error(`Module not found: ${moduleName}`); + } + return false; + } + + const mod = result.module; + const tasks = mod[operation] as Task[] | undefined; + + if (!tasks || tasks.length === 0) { + console.log(`Module ${moduleName} has no ${operation} tasks`); + return true; + } + + // Load plugins + const registry = getPluginRegistry(); + await registry.loadBuiltinPlugins(); + + // Create executor with progress events + const executor = new TaskExecutor({ + dryRun: options.dryRun, + }); + + // Subscribe to events for progress output + executor.on("task:start", (task, index, total) => { + const taskName = + "name" in task && typeof task.name === "string" ? task.name : `Task ${index + 1}`; + process.stdout.write(`[${index + 1}/${total}] ${taskName}...`); + }); + + executor.on("task:complete", (task, taskResult, _index, _total) => { + if (taskResult.success) { + const status = taskResult.changed ? "ok" : "unchanged"; + console.log(` ${status}`); + } else { + console.log(` FAILED`); + if (taskResult.message) { + console.error(` Error: ${taskResult.message}`); + } + } + }); + + executor.on("task:error", (_task, error, _index, _total) => { + console.log(` ERROR`); + console.error(` ${error.message}`); + }); + + // Execute tasks + const verbMap: Record = { + install: "Installing", + remove: "Removing", + start: "Starting", + stop: "Stopping", + }; + console.log(`${verbMap[operation]} ${moduleName}...`); + console.log(""); + + const results = await executor.execute(tasks, operation); + + // Summary + console.log(""); + const changes = getChanges(results); + const failures = getFailures(results); + + if (allSucceeded(results)) { + if (options.dryRun) { + console.log(`Dry run complete. ${changes.length} task(s) would make changes.`); + } else { + console.log(`${operation.charAt(0).toUpperCase() + operation.slice(1)} complete.`); + console.log(`${changes.length} task(s) made changes.`); + + // Update state on successful install/remove + if (operation === "install") { + await stateManager.installModule(moduleName); + } else if (operation === "remove") { + await stateManager.removeModule(moduleName); + } + } + return true; + } + + console.error(`${operation.charAt(0).toUpperCase() + operation.slice(1)} failed.`); + console.error(`${failures.length} task(s) failed.`); + return false; +} + +/** + * Execute a module operation (install/remove/start/stop) + */ +async function executeModuleOperation( + moduleName: string, + operation: Operation, + options: ModuleOperationOptions = {}, +): Promise { + // Load module to verify it exists + let result: Awaited>; + try { + result = await loadModule(moduleName); + } catch (error) { + if (handleModulesNotFoundError(error)) { + process.exit(1); + } + throw error; + } + + if (!result.success || !result.module) { + if (result.error) { + console.error(formatModuleLoadError(result.error)); + } else { + console.error(`Module not found: ${moduleName}`); + } + process.exit(1); + } + + // Check lock mode + const stateManager = StateManager.getInstance(); + const isLocked = await stateManager.isLocked(); + + if (isLocked && operation !== "start" && operation !== "stop") { + const lockState = await stateManager.getLockState(); + console.error("System is locked. Cannot modify modules."); + if (lockState.message) { + console.error(`Reason: ${lockState.message}`); + } + console.error("Use 'katana unlock' to disable lock mode."); + process.exit(1); + } + + console.log(""); + + // Handle dependencies for install + if (operation === "install") { + const allModulesResult = await loadAllModules(); + const resolver = new DependencyResolver(allModulesResult.modules); + + // Resolve installation order + const resolution = resolver.getInstallOrder(moduleName); + if (!resolution.success) { + for (const error of resolution.errors) { + console.error(`Dependency error: ${error.message}`); + } + process.exit(1); + } + + // Install dependencies first (in order), skipping already installed + const statusChecker = new StatusChecker(); + for (const depName of resolution.order) { + if (depName.toLowerCase() === moduleName.toLowerCase()) continue; + + // Check if already installed + const depModule = allModulesResult.modules.find( + (m) => m.name.toLowerCase() === depName.toLowerCase(), + ); + if (depModule) { + const status = await statusChecker.checkStatus(depModule); + if (status.installed) { + console.log(`Dependency ${depName} already installed, skipping\n`); + continue; + } + } + + // Install dependency (fail-fast) + console.log(`Installing dependency: ${depName}\n`); + const success = await executeModuleTasks(depName, "install", options, stateManager); + if (!success) { + console.error(`\nFailed to install dependency: ${depName}`); + console.error("Aborting installation."); + process.exit(1); + } + console.log(""); + } + } + + // Handle warning for remove when dependents exist + if (operation === "remove") { + const allModulesResult = await loadAllModules(); + const resolver = new DependencyResolver(allModulesResult.modules); + + const dependents = resolver.getDependents(moduleName); + if (dependents.length > 0) { + console.warn(`Warning: The following modules depend on ${moduleName}:`); + for (const dep of dependents) { + console.warn(` - ${dep}`); + } + console.warn("Proceeding with removal...\n"); + } + } + + // Execute the main module operation + const success = await executeModuleTasks(moduleName, operation, options, stateManager); + if (!success) { + process.exit(1); + } +} + +program + .command("install") + .description("Install a module") + .argument("", "Module name to install") + .option("--dry-run", "Show what would be done without executing") + .action(async (moduleName: string, options: ModuleOperationOptions) => { + await executeModuleOperation(moduleName, "install", options); + }); + +program + .command("remove") + .description("Remove a module") + .argument("", "Module name to remove") + .option("--dry-run", "Show what would be done without executing") + .action(async (moduleName: string, options: ModuleOperationOptions) => { + await executeModuleOperation(moduleName, "remove", options); + }); + +program + .command("start") + .description("Start module services") + .argument("", "Module name to start") + .option("--dry-run", "Show what would be done without executing") + .action(async (moduleName: string, options: ModuleOperationOptions) => { + await executeModuleOperation(moduleName, "start", options); + }); + +program + .command("stop") + .description("Stop module services") + .argument("", "Module name to stop") + .option("--dry-run", "Show what would be done without executing") + .action(async (moduleName: string, options: ModuleOperationOptions) => { + await executeModuleOperation(moduleName, "stop", options); + }); + +program + .command("lock") + .description("Enable lock mode (prevent changes)") + .option("-m, --message ", "Lock message explaining the reason") + .action(async (options: { message?: string }) => { + try { + const stateManager = StateManager.getInstance(); + + // Check if already locked + if (await stateManager.isLocked()) { + const state = await stateManager.getLockState(); + console.log("System is already locked."); + if (state.lockedBy) { + console.log(`Locked by: ${state.lockedBy}`); + } + if (state.message) { + console.log(`Reason: ${state.message}`); + } + return; + } + + await stateManager.enableLock({ + message: options.message, + lockedBy: process.env.USER ?? "unknown", + }); + + const installed = await stateManager.getInstalledModuleNames(); + console.log("Lock mode enabled."); + console.log(`Locked modules: ${installed.length > 0 ? installed.join(", ") : "(none)"}`); + } catch (error) { + console.error("Error enabling lock:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +program + .command("unlock") + .description("Disable lock mode (allow changes)") + .action(async () => { + try { + const stateManager = StateManager.getInstance(); + + // Check if not locked + if (!(await stateManager.isLocked())) { + console.log("System is not locked."); + return; + } + + await stateManager.disableLock(); + console.log("Lock mode disabled."); + } catch (error) { + console.error("Error disabling lock:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Update Command +// ============================================================================= + +interface UpdateOptions { + branch?: string; + force?: boolean; +} + +program + .command("update") + .description("Fetch or update modules from GitHub") + .option("-b, --branch ", "Git branch to use (overrides config)") + .option("--force", "Force re-clone even if modules exist") + .action(async (options: UpdateOptions) => { + try { + // Load config for repo and branch defaults + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + const repo = config.modulesRepo; + const branch = options.branch ?? config.modulesBranch; + + console.log(""); + console.log("Updating modules..."); + console.log(` Repository: ${repo}`); + console.log(` Branch: ${branch}`); + console.log(""); + + const fetcher = new ModuleFetcher(); + const result = await fetcher.fetchModules({ repo, branch }); + + if (result.success) { + console.log( + result.isUpdate ? "Modules updated successfully." : "Modules cloned successfully.", + ); + console.log(` Location: ${result.modulesPath}`); + console.log(""); + + // Reset the ModuleLoader singleton to pick up new path + ModuleLoader.resetInstance(); + + // Verify modules are accessible + const loader = ModuleLoader.getInstance(config); + if (loader.hasModules()) { + const modules = await loader.getModuleNames(); + console.log(`Found ${modules.length} modules.`); + } + } else { + console.error("Failed to update modules:"); + console.error(` ${result.message}`); + process.exit(1); + } + } catch (error) { + console.error("Error updating modules:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Cert Command +// ============================================================================= + +const certCmd = program.command("cert").description("Certificate management"); + +certCmd + .command("init") + .description("Generate root CA and wildcard certificate") + .option("--force", "Regenerate certificates even if they exist") + .action(async (options: { force?: boolean }) => { + try { + // Load config to get domainBase and statePath + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + console.log(""); + console.log("Certificate Initialization"); + console.log("=========================="); + console.log(""); + console.log(`Domain: *.${config.domainBase}`); + console.log(`State path: ${config.statePath}`); + console.log(""); + + // Initialize cert manager with config + const certManager = CertManager.getInstance(); + certManager.setStatePath(config.statePath); + + const result = await certManager.initCerts(config.domainBase, options.force); + + if (result.success) { + console.log(result.message); + if (result.certPaths) { + console.log(""); + console.log("Certificate files:"); + console.log(` Root CA: ${result.certPaths.rootCACert}`); + console.log(` Wildcard: ${result.certPaths.cert}`); + console.log(""); + console.log("Next steps:"); + console.log(" 1. Run 'sudo katana cert install-ca' to trust the root CA system-wide"); + console.log(" 2. Or import the root CA into your browser manually"); + } + } else { + console.error(`Error: ${result.message}`); + process.exit(1); + } + } catch (error) { + console.error("Error initializing certificates:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +certCmd + .command("install-ca") + .description("Install root CA to system trust store (requires sudo)") + .action(async () => { + try { + // Load config to get statePath + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + // Initialize cert manager with config + const certManager = CertManager.getInstance(); + certManager.setStatePath(config.statePath); + + console.log(""); + console.log("Installing Root CA to system trust store..."); + console.log(""); + + const result = await certManager.installCA(); + + if (result.success) { + console.log(result.message); + console.log(""); + console.log("The Katana root CA is now trusted system-wide."); + console.log("Browsers may need to be restarted to pick up the change."); + } else { + console.error(`Error: ${result.message}`); + process.exit(1); + } + } catch (error) { + console.error("Error installing CA:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +certCmd + .command("status") + .description("Show certificate status") + .action(async () => { + try { + // Load config to get statePath + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + // Initialize cert manager with config + const certManager = CertManager.getInstance(); + certManager.setStatePath(config.statePath); + + console.log(""); + console.log("Certificate Status"); + console.log("=================="); + console.log(""); + + const hasCerts = await certManager.hasCerts(); + const state = await certManager.getCertState(); + + if (hasCerts && state) { + console.log(`Status: Initialized`); + console.log(`Domain: *.${state.domainBase}`); + console.log(`Created: ${state.createdAt}`); + console.log(""); + + const paths = certManager.getCertPaths(); + console.log("Certificate files:"); + console.log(` Root CA cert: ${paths.rootCACert}`); + console.log(` Root CA key: ${paths.rootCAKey}`); + console.log(` Wildcard cert: ${paths.cert}`); + console.log(` Wildcard key: ${paths.key}`); + } else { + console.log(`Status: Not initialized`); + console.log(""); + console.log("Run 'katana cert init' to generate certificates."); + } + } catch (error) { + console.error("Error checking certificate status:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Service Command (systemd) +// ============================================================================= + +const SYSTEMD_SERVICE_PATH = "/etc/systemd/system/katana.service"; + +const SYSTEMD_SERVICE_CONTENT = `[Unit] +Description=Katana Module Management Server +After=network.target docker.service +Wants=docker.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/katana serve --tls --host 0.0.0.0 +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/katana /etc/nginx/sites-available /etc/nginx/sites-enabled + +# Environment +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +`; + +const serviceCmd = program.command("service").description("Manage katana systemd service"); + +serviceCmd + .command("install") + .description("Install and enable systemd service (requires sudo)") + .option("--no-tls", "Run server without TLS") + .option("--port ", "Override default port", Number.parseInt) + .action(async (options: { tls?: boolean; port?: number }) => { + try { + // Check if running as root + if (process.getuid?.() !== 0) { + console.error("Error: This command requires root privileges."); + console.error("Run with: sudo katana service install"); + process.exit(1); + } + + // Build ExecStart line based on options + let execStart = "/usr/local/bin/katana serve --host 0.0.0.0"; + if (options.tls !== false) { + execStart += " --tls"; + } + if (options.port) { + execStart += ` --port ${options.port}`; + } + + // Customize service content + const serviceContent = SYSTEMD_SERVICE_CONTENT.replace( + /ExecStart=.*/, + `ExecStart=${execStart}`, + ); + + console.log(""); + console.log("Installing Katana systemd service..."); + console.log(""); + + // Write service file + await Bun.write(SYSTEMD_SERVICE_PATH, serviceContent); + console.log(`Created ${SYSTEMD_SERVICE_PATH}`); + + // Reload systemd + await Bun.$`systemctl daemon-reload`.quiet(); + console.log("Reloaded systemd configuration"); + + // Enable service + await Bun.$`systemctl enable katana.service`.quiet(); + console.log("Enabled katana.service"); + + console.log(""); + console.log("Service installed successfully."); + console.log(""); + console.log("Commands:"); + console.log(" sudo systemctl start katana # Start the service"); + console.log(" sudo systemctl stop katana # Stop the service"); + console.log(" sudo systemctl status katana # Check status"); + console.log(" journalctl -u katana -f # View logs"); + } catch (error) { + console.error("Error installing service:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +serviceCmd + .command("uninstall") + .description("Stop and remove systemd service (requires sudo)") + .action(async () => { + try { + // Check if running as root + if (process.getuid?.() !== 0) { + console.error("Error: This command requires root privileges."); + console.error("Run with: sudo katana service uninstall"); + process.exit(1); + } + + console.log(""); + console.log("Uninstalling Katana systemd service..."); + console.log(""); + + // Stop service if running + try { + await Bun.$`systemctl stop katana.service`.quiet(); + console.log("Stopped katana.service"); + } catch { + // Service might not be running + } + + // Disable service + try { + await Bun.$`systemctl disable katana.service`.quiet(); + console.log("Disabled katana.service"); + } catch { + // Service might not be enabled + } + + // Remove service file + const file = Bun.file(SYSTEMD_SERVICE_PATH); + if (await file.exists()) { + await Bun.$`rm ${SYSTEMD_SERVICE_PATH}`.quiet(); + console.log(`Removed ${SYSTEMD_SERVICE_PATH}`); + } + + // Reload systemd + await Bun.$`systemctl daemon-reload`.quiet(); + console.log("Reloaded systemd configuration"); + + console.log(""); + console.log("Service uninstalled."); + } catch (error) { + console.error("Error uninstalling service:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +serviceCmd + .command("status") + .description("Show systemd service status") + .action(async () => { + try { + const file = Bun.file(SYSTEMD_SERVICE_PATH); + if (!(await file.exists())) { + console.log("Katana service is not installed."); + console.log("Run 'sudo katana service install' to install it."); + return; + } + + // Get service status + const result = await Bun.$`systemctl status katana.service --no-pager`.nothrow(); + console.log(result.stdout.toString()); + } catch (error) { + console.error("Error checking service status:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Serve Command +// ============================================================================= + +interface ServeOptions { + port?: number; + host?: string; + cors?: boolean; + tls?: boolean; +} + +program + .command("serve") + .description("Start the REST API server") + .option("-p, --port ", "Port to listen on", Number.parseInt) + .option("--host ", "Host to bind to") + .option("--cors", "Enable CORS for development") + .option("--tls", "Enable TLS using Katana certificates") + .action(async (options: ServeOptions) => { + try { + // Load config + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + // Override config with CLI options + if (options.port) { + config.server.port = options.port; + } + if (options.host) { + config.server.host = options.host; + } + if (options.cors) { + config.server.cors = true; + } + + // Handle TLS option + let tlsConfig: { cert: string; key: string } | undefined; + if (options.tls) { + const certManager = CertManager.getInstance(); + certManager.setStatePath(config.statePath); + + if (!(await certManager.hasCerts())) { + console.error("Error: Certificates not initialized."); + console.error("Run 'katana cert init' first to generate certificates."); + process.exit(1); + } + + const certPaths = certManager.getCertPaths(); + tlsConfig = { + cert: certPaths.cert, + key: certPaths.key, + }; + } + + // Start server + createServer({ config, tls: tlsConfig }); + printServerInfo(config, options.tls); + } catch (error) { + console.error("Error starting server:", error instanceof Error ? error.message : error); + process.exit(1); + } + }); + +// ============================================================================= +// Parse and run +// ============================================================================= + +program.parse(); diff --git a/bun/src/core/cert-manager.ts b/bun/src/core/cert-manager.ts new file mode 100644 index 0000000..b9b733e --- /dev/null +++ b/bun/src/core/cert-manager.ts @@ -0,0 +1,361 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as yamlParse, stringify as yamlStringify } from "yaml"; +import { CERT_FILES, CertStateSchema, type CertPaths, type CertState } from "../types/cert"; +import { getMockState, isMockMode } from "./mock-state"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface CertManagerOptions { + /** Override state path for testing */ + statePath?: string; +} + +export interface InitCertsResult { + success: boolean; + message: string; + certPaths?: CertPaths; +} + +export interface InstallCAResult { + success: boolean; + message: string; +} + +// ============================================================================= +// CertManager Class +// ============================================================================= + +export class CertManager { + private statePath: string; + private certState: CertState | null = null; + private loaded = false; + + private static instance: CertManager | null = null; + + constructor(options?: CertManagerOptions) { + this.statePath = options?.statePath ?? "/var/lib/katana"; + } + + /** + * Get or create the singleton instance + */ + static getInstance(options?: CertManagerOptions): CertManager { + if (!CertManager.instance) { + CertManager.instance = new CertManager(options); + } + return CertManager.instance; + } + + /** + * Reset singleton (useful for testing) + */ + static resetInstance(): void { + CertManager.instance = null; + } + + /** + * Set the state path (called after config is loaded) + */ + setStatePath(statePath: string): void { + this.statePath = this.expandPath(statePath); + // Reset loaded state so we reload from new path + this.loaded = false; + this.certState = null; + } + + /** + * Expand ~ to home directory in path + */ + private expandPath(path: string): string { + if (path.startsWith("~/")) { + return path.replace("~", homedir()); + } + return path; + } + + /** + * Get the certs directory path + */ + getCertsDir(): string { + return join(this.statePath, "certs"); + } + + /** + * Get paths to all certificate files + */ + getCertPaths(): CertPaths { + const certsDir = this.getCertsDir(); + return { + cert: join(certsDir, CERT_FILES.WILDCARD_CERT), + key: join(certsDir, CERT_FILES.WILDCARD_KEY), + rootCACert: join(certsDir, CERT_FILES.ROOT_CA_CERT), + rootCAKey: join(certsDir, CERT_FILES.ROOT_CA_KEY), + }; + } + + /** + * Load certificate state from cert-state.yml + */ + async loadCertState(): Promise { + if (this.loaded) { + return this.certState; + } + + // Mock mode + if (isMockMode()) { + const mockState = getMockState().getCertState(); + this.certState = mockState + ? { + initialized: mockState.initialized, + domainBase: mockState.domainBase, + createdAt: mockState.createdAt, + } + : null; + this.loaded = true; + return this.certState; + } + + const stateFile = join(this.getCertsDir(), CERT_FILES.STATE_FILE); + const file = Bun.file(stateFile); + + if (!(await file.exists())) { + this.certState = null; + this.loaded = true; + return null; + } + + try { + const content = await file.text(); + const parsed = yamlParse(content); + const result = CertStateSchema.safeParse(parsed); + + if (result.success) { + this.certState = result.data; + this.loaded = true; + return this.certState; + } + + console.warn("Warning: Invalid cert state file, treating as uninitialized"); + this.certState = null; + this.loaded = true; + return null; + } catch { + this.certState = null; + this.loaded = true; + return null; + } + } + + /** + * Save certificate state to cert-state.yml + */ + private async saveCertState(state: CertState): Promise { + const stateFile = join(this.getCertsDir(), CERT_FILES.STATE_FILE); + const content = yamlStringify(state); + await Bun.write(stateFile, content); + this.certState = state; + } + + /** + * Check if certificates have been initialized + */ + async hasCerts(): Promise { + // Mock mode + if (isMockMode()) { + return getMockState().hasCerts(); + } + + const state = await this.loadCertState(); + if (!state?.initialized) { + return false; + } + + // Verify the actual cert files exist + const paths = this.getCertPaths(); + const certFile = Bun.file(paths.cert); + const keyFile = Bun.file(paths.key); + + return (await certFile.exists()) && (await keyFile.exists()); + } + + /** + * Get the current certificate state + */ + async getCertState(): Promise { + return this.loadCertState(); + } + + /** + * Initialize certificates for the given domain base + */ + async initCerts(domainBase: string, force = false): Promise { + // Mock mode + if (isMockMode()) { + getMockState().initCerts(domainBase); + return { + success: true, + message: `[mock] Certificates initialized for *.${domainBase}`, + certPaths: this.getCertPaths(), + }; + } + + // Check if already initialized + if (!force && (await this.hasCerts())) { + const state = await this.loadCertState(); + if (state?.domainBase === domainBase) { + return { + success: true, + message: `Certificates already exist for *.${domainBase}`, + certPaths: this.getCertPaths(), + }; + } + return { + success: false, + message: `Certificates exist for *.${state?.domainBase}. Use --force to regenerate for *.${domainBase}`, + }; + } + + // Check for openssl + try { + await Bun.$`which openssl`.quiet(); + } catch { + return { + success: false, + message: "OpenSSL not found. Please install OpenSSL to generate certificates.", + }; + } + + const certsDir = this.getCertsDir(); + const paths = this.getCertPaths(); + + try { + // Create certs directory + await Bun.$`mkdir -p ${certsDir}`.quiet(); + + // Generate Root CA private key (4096 bit) + console.log("Generating Root CA private key..."); + await Bun.$`openssl genrsa -out ${paths.rootCAKey} 4096`.quiet(); + + // Generate Root CA certificate (10 years) + console.log("Generating Root CA certificate..."); + await Bun.$`openssl req -x509 -new -nodes -key ${paths.rootCAKey} -sha256 -days 3650 -out ${paths.rootCACert} -subj "/CN=Katana Root CA/O=Katana"`.quiet(); + + // Generate wildcard private key (2048 bit) + console.log("Generating wildcard private key..."); + const wildcardKey = join(certsDir, CERT_FILES.WILDCARD_KEY); + await Bun.$`openssl genrsa -out ${wildcardKey} 2048`.quiet(); + + // Generate CSR for wildcard cert + console.log("Generating certificate signing request..."); + const wildcardCsr = join(certsDir, CERT_FILES.WILDCARD_CSR); + await Bun.$`openssl req -new -key ${wildcardKey} -out ${wildcardCsr} -subj "/CN=*.${domainBase}/O=Katana"`.quiet(); + + // Create extensions file for SAN + const wildcardExt = join(certsDir, CERT_FILES.WILDCARD_EXT); + const extContent = `authorityKeyIdentifier=keyid,issuer +basicConstraints=CA:FALSE +keyUsage=digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment +subjectAltName=@alt_names + +[alt_names] +DNS.1=*.${domainBase} +DNS.2=${domainBase} +`; + await Bun.write(wildcardExt, extContent); + + // Sign wildcard cert with Root CA (2 years) + console.log("Signing wildcard certificate..."); + await Bun.$`openssl x509 -req -in ${wildcardCsr} -CA ${paths.rootCACert} -CAkey ${paths.rootCAKey} -CAcreateserial -out ${paths.cert} -days 730 -sha256 -extfile ${wildcardExt} -extensions v3_req`.quiet(); + + // Clean up temporary files + await Bun.$`rm -f ${wildcardCsr} ${wildcardExt}`.quiet(); + + // Save state + const state: CertState = { + initialized: true, + domainBase, + createdAt: new Date().toISOString(), + }; + await this.saveCertState(state); + + return { + success: true, + message: `Certificates generated for *.${domainBase}`, + certPaths: paths, + }; + } catch (error) { + return { + success: false, + message: `Failed to generate certificates: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Install Root CA to system trust store + */ + async installCA(): Promise { + // Mock mode + if (isMockMode()) { + return { + success: true, + message: "[mock] Root CA installed to system trust store", + }; + } + + // Check if certs exist + if (!(await this.hasCerts())) { + return { + success: false, + message: "Certificates not initialized. Run 'katana cert init' first.", + }; + } + + const paths = this.getCertPaths(); + const destPath = "/usr/local/share/ca-certificates/katana-root-ca.crt"; + + try { + // Copy root CA to system trust store + console.log(`Copying Root CA to ${destPath}...`); + await Bun.$`cp ${paths.rootCACert} ${destPath}`.quiet(); + + // Update CA certificates + console.log("Updating CA certificates..."); + await Bun.$`update-ca-certificates`.quiet(); + + return { + success: true, + message: "Root CA installed to system trust store", + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + + // Check for permission error + if (errorMsg.includes("Permission denied") || errorMsg.includes("EACCES")) { + return { + success: false, + message: `Permission denied. Try running with sudo:\n sudo katana cert install-ca`, + }; + } + + return { + success: false, + message: `Failed to install CA: ${errorMsg}`, + }; + } + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Get the singleton CertManager instance + */ +export function getCertManager(options?: CertManagerOptions): CertManager { + return CertManager.getInstance(options); +} diff --git a/bun/src/core/config-manager.ts b/bun/src/core/config-manager.ts new file mode 100644 index 0000000..29979ac --- /dev/null +++ b/bun/src/core/config-manager.ts @@ -0,0 +1,171 @@ +import { homedir } from "node:os"; +import { parse as yamlParse } from "yaml"; +import { CONFIG_PATHS, type Config, ConfigSchema, DEFAULT_CONFIG } from "../types/config"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface ConfigManagerOptions { + /** Override config paths for testing */ + configPaths?: readonly string[]; +} + +// ============================================================================= +// ConfigManager Class +// ============================================================================= + +export class ConfigManager { + private config: Config | null = null; + private configPath: string | null = null; + private configPaths: readonly string[]; + private loaded = false; + + private static instance: ConfigManager | null = null; + + constructor(options?: ConfigManagerOptions) { + this.configPaths = options?.configPaths ?? CONFIG_PATHS; + } + + /** + * Get or create the singleton instance + */ + static getInstance(options?: ConfigManagerOptions): ConfigManager { + if (!ConfigManager.instance) { + ConfigManager.instance = new ConfigManager(options); + } + return ConfigManager.instance; + } + + /** + * Reset singleton (useful for testing) + */ + static resetInstance(): void { + ConfigManager.instance = null; + } + + /** + * Expand ~ to home directory in path + */ + private expandPath(path: string): string { + if (path.startsWith("~/")) { + return path.replace("~", homedir()); + } + return path; + } + + /** + * Find the first existing config file from config paths + */ + private async findConfigFile(): Promise { + for (const path of this.configPaths) { + const expandedPath = this.expandPath(path); + const file = Bun.file(expandedPath); + if (await file.exists()) { + return expandedPath; + } + } + return null; + } + + /** + * Load configuration from the first existing config file. + * Falls back to DEFAULT_CONFIG if no file found or invalid. + */ + async loadConfig(): Promise { + // Return cached config if already loaded + if (this.loaded && this.config) { + return this.config; + } + + const configFile = await this.findConfigFile(); + + if (!configFile) { + // No config file found, use defaults + this.config = DEFAULT_CONFIG; + this.configPath = null; + this.loaded = true; + return this.config; + } + + try { + const file = Bun.file(configFile); + const content = await file.text(); + const parsed = yamlParse(content); + + const result = ConfigSchema.safeParse(parsed); + + if (result.success) { + this.config = result.data; + this.configPath = configFile; + this.loaded = true; + return this.config; + } + + // Zod validation failed + console.warn(`Warning: Invalid config format in ${configFile}, using defaults`); + if (result.error?.issues) { + console.warn(` ${result.error.issues.map((e) => e.message).join(", ")}`); + } + this.config = DEFAULT_CONFIG; + this.configPath = null; + this.loaded = true; + return this.config; + } catch (error) { + // YAML parse error or file read error + console.warn(`Warning: Error reading config from ${configFile}, using defaults`); + if (error instanceof Error) { + console.warn(` ${error.message}`); + } + this.config = DEFAULT_CONFIG; + this.configPath = null; + this.loaded = true; + return this.config; + } + } + + /** + * Get the currently loaded config (or DEFAULT_CONFIG if not loaded) + */ + getConfig(): Config { + if (this.config) { + return this.config; + } + return DEFAULT_CONFIG; + } + + /** + * Get the path of the loaded config file, or null if using defaults + */ + getConfigPath(): string | null { + return this.configPath; + } + + /** + * Check if config has been loaded + */ + isLoaded(): boolean { + return this.loaded; + } + + /** + * Force reload config from file system + */ + async reloadConfig(): Promise { + this.loaded = false; + this.config = null; + this.configPath = null; + return this.loadConfig(); + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Get the singleton ConfigManager instance + */ +export function getConfigManager(options?: ConfigManagerOptions): ConfigManager { + return ConfigManager.getInstance(options); +} diff --git a/bun/src/core/dependencies.ts b/bun/src/core/dependencies.ts new file mode 100644 index 0000000..8054dbd --- /dev/null +++ b/bun/src/core/dependencies.ts @@ -0,0 +1,415 @@ +import type { LoadedModule } from "./module-loader"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Dependency graph structure + */ +export interface DependencyGraph { + /** Map of module name -> array of dependency module names */ + edges: Map; + /** All module names in the graph */ + nodes: Set; +} + +/** + * Error information for dependency resolution failures + */ +export interface DependencyError { + type: "circular" | "missing"; + message: string; + details: { + module: string; + chain?: string[]; // For circular: the cycle path + missing?: string; // For missing: the missing dependency + }; +} + +/** + * Result of dependency resolution + */ +export interface ResolutionResult { + success: boolean; + /** Modules in installation order (topologically sorted) */ + order: string[]; + errors: DependencyError[]; +} + +// ============================================================================= +// DependencyResolver Class +// ============================================================================= + +/** + * Resolves module dependencies using graph algorithms. + * + * Features: + * - Build dependency graph from module definitions + * - Detect circular dependencies using DFS with 3-color marking + * - Topological sort for installation order using Kahn's algorithm + * - Find reverse dependencies (modules that depend on a given module) + */ +export class DependencyResolver { + private modules: Map; + private graph: DependencyGraph; + + constructor(modules: LoadedModule[]) { + this.modules = new Map(); + for (const mod of modules) { + this.modules.set(mod.name.toLowerCase(), mod); + } + this.graph = this.buildGraph(); + } + + /** + * Build dependency graph from all modules + */ + buildGraph(): DependencyGraph { + const edges = new Map(); + const nodes = new Set(); + + for (const module of this.modules.values()) { + const moduleName = module.name.toLowerCase(); + nodes.add(moduleName); + + const deps = module["depends-on"] ?? []; + const normalizedDeps = deps.map((d) => d.toLowerCase()); + edges.set(moduleName, normalizedDeps); + + // Add dependency nodes (even if module not loaded) + for (const dep of normalizedDeps) { + nodes.add(dep); + } + } + + return { edges, nodes }; + } + + /** + * Detect circular dependencies using DFS with 3-color marking. + * + * Colors: WHITE (0) = unvisited, GRAY (1) = in current path, BLACK (2) = done + * A cycle exists when we encounter a GRAY node. + */ + detectCircularDependencies(): DependencyError[] { + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + + const color = new Map(); + const errors: DependencyError[] = []; + + // Initialize all nodes as WHITE + for (const node of this.graph.nodes) { + color.set(node, WHITE); + } + + const dfs = (node: string, path: string[]): void => { + color.set(node, GRAY); + + const deps = this.graph.edges.get(node) ?? []; + for (const dep of deps) { + if (color.get(dep) === GRAY) { + // Found cycle - extract the cycle path + const cycleStart = path.indexOf(dep); + const cycle = cycleStart >= 0 ? [...path.slice(cycleStart), dep] : [node, dep]; + + errors.push({ + type: "circular", + message: `Circular dependency detected: ${cycle.join(" -> ")}`, + details: { + module: node, + chain: cycle, + }, + }); + } else if (color.get(dep) === WHITE) { + dfs(dep, [...path, dep]); + } + } + + color.set(node, BLACK); + }; + + // Run DFS from each unvisited node + for (const node of this.graph.nodes) { + if (color.get(node) === WHITE) { + dfs(node, [node]); + } + } + + return errors; + } + + /** + * Validate that all dependencies reference existing modules + */ + validateDependencies(): DependencyError[] { + const errors: DependencyError[] = []; + + for (const [moduleName, deps] of this.graph.edges) { + for (const dep of deps) { + if (!this.modules.has(dep)) { + errors.push({ + type: "missing", + message: `Module '${moduleName}' depends on '${dep}' which does not exist`, + details: { + module: moduleName, + missing: dep, + }, + }); + } + } + } + + return errors; + } + + /** + * Get all transitive dependencies of a module (not including the module itself) + */ + private getTransitiveDependencies(moduleName: string): Set { + const result = new Set(); + const visited = new Set(); + const normalizedName = moduleName.toLowerCase(); + + const visit = (name: string): void => { + if (visited.has(name)) return; + visited.add(name); + + const deps = this.graph.edges.get(name) ?? []; + for (const dep of deps) { + result.add(dep); + visit(dep); + } + }; + + visit(normalizedName); + return result; + } + + /** + * Get topologically sorted installation order for a target module. + * Uses Kahn's algorithm (BFS with in-degree tracking). + * + * The returned order includes all dependencies plus the target module, + * with dependencies coming before modules that depend on them. + */ + getInstallOrder(targetModule: string): ResolutionResult { + const normalizedTarget = targetModule.toLowerCase(); + + // Check if target exists + if (!this.modules.has(normalizedTarget)) { + return { + success: false, + order: [], + errors: [ + { + type: "missing", + message: `Module '${targetModule}' not found`, + details: { + module: targetModule, + missing: targetModule, + }, + }, + ], + }; + } + + // Get all modules needed (target + its transitive deps) + const needed = this.getTransitiveDependencies(normalizedTarget); + needed.add(normalizedTarget); + + // Validate all needed modules exist + const missingErrors: DependencyError[] = []; + for (const name of needed) { + if (!this.modules.has(name)) { + const dependedBy = this.findWhoNeeds(name, needed); + missingErrors.push({ + type: "missing", + message: `Module '${dependedBy}' depends on '${name}' which does not exist`, + details: { + module: dependedBy, + missing: name, + }, + }); + } + } + + if (missingErrors.length > 0) { + return { + success: false, + order: [], + errors: missingErrors, + }; + } + + // Check for circular dependencies in the needed subset + const circularErrors = this.detectCircularDependenciesInSubset(needed); + if (circularErrors.length > 0) { + return { + success: false, + order: [], + errors: circularErrors, + }; + } + + // Kahn's algorithm for topological sort + // Calculate in-degree for each node (within the needed subset) + const inDegree = new Map(); + for (const node of needed) { + inDegree.set(node, 0); + } + + // For each needed node, count how many of its dependencies are also needed + for (const node of needed) { + const deps = this.graph.edges.get(node) ?? []; + for (const dep of deps) { + if (needed.has(dep)) { + // This node depends on dep, so dep should come first + // We count how many things depend ON each node + inDegree.set(node, (inDegree.get(node) ?? 0) + 1); + } + } + } + + // Start with nodes that have no dependencies (in-degree = 0) + const queue: string[] = []; + for (const [node, degree] of inDegree) { + if (degree === 0) { + queue.push(node); + } + } + + const order: string[] = []; + while (queue.length > 0) { + const node = queue.shift()!; + order.push(node); + + // For each module that depends on this node, decrement its in-degree + for (const other of needed) { + const deps = this.graph.edges.get(other) ?? []; + if (deps.includes(node)) { + const newDegree = (inDegree.get(other) ?? 1) - 1; + inDegree.set(other, newDegree); + if (newDegree === 0) { + queue.push(other); + } + } + } + } + + // If we didn't process all nodes, there's a cycle (shouldn't happen after check) + if (order.length !== needed.size) { + return { + success: false, + order: [], + errors: [ + { + type: "circular", + message: "Unexpected cycle detected during topological sort", + details: { module: normalizedTarget }, + }, + ], + }; + } + + return { + success: true, + order, + errors: [], + }; + } + + /** + * Find which module in the needed set requires the given dependency + */ + private findWhoNeeds(dep: string, needed: Set): string { + for (const mod of needed) { + const deps = this.graph.edges.get(mod) ?? []; + if (deps.includes(dep)) { + return mod; + } + } + return "unknown"; + } + + /** + * Detect circular dependencies within a subset of nodes + */ + private detectCircularDependenciesInSubset(subset: Set): DependencyError[] { + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + + const color = new Map(); + const errors: DependencyError[] = []; + + for (const node of subset) { + color.set(node, WHITE); + } + + const dfs = (node: string, path: string[]): void => { + color.set(node, GRAY); + + const deps = this.graph.edges.get(node) ?? []; + for (const dep of deps) { + if (!subset.has(dep)) continue; // Only check within subset + + if (color.get(dep) === GRAY) { + const cycleStart = path.indexOf(dep); + const cycle = cycleStart >= 0 ? [...path.slice(cycleStart), dep] : [node, dep]; + errors.push({ + type: "circular", + message: `Circular dependency detected: ${cycle.join(" -> ")}`, + details: { module: node, chain: cycle }, + }); + } else if (color.get(dep) === WHITE) { + dfs(dep, [...path, dep]); + } + } + + color.set(node, BLACK); + }; + + for (const node of subset) { + if (color.get(node) === WHITE) { + dfs(node, [node]); + } + } + + return errors; + } + + /** + * Get all modules that depend on the given module (reverse lookup). + * Useful for warning when removing a module that others depend on. + */ + getDependents(moduleName: string): string[] { + const normalizedName = moduleName.toLowerCase(); + const dependents: string[] = []; + + for (const [modName, deps] of this.graph.edges) { + if (deps.includes(normalizedName)) { + dependents.push(modName); + } + } + + return dependents; + } + + /** + * Check if a module has any dependencies + */ + hasDependencies(moduleName: string): boolean { + const deps = this.graph.edges.get(moduleName.toLowerCase()); + return deps !== undefined && deps.length > 0; + } + + /** + * Get direct dependencies of a module + */ + getDependencies(moduleName: string): string[] { + return this.graph.edges.get(moduleName.toLowerCase()) ?? []; + } +} diff --git a/bun/src/core/executor.ts b/bun/src/core/executor.ts new file mode 100644 index 0000000..3875408 --- /dev/null +++ b/bun/src/core/executor.ts @@ -0,0 +1,237 @@ +/** + * Task executor for running module task lists. + * Executes tasks sequentially using registered plugins with EventEmitter for progress. + */ + +import { EventEmitter } from "events"; +import { getPluginRegistry } from "../plugins/registry"; +import type { Task } from "../types/module"; +import type { ExecutionContext, Logger, Operation, PluginResult } from "../types/plugin"; +import { isMockMode } from "./mock-state"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface TaskResult { + task: Task; + result: PluginResult; + duration: number; +} + +export interface ExecutorOptions { + /** Override mock mode (defaults to KATANA_MOCK env) */ + mock?: boolean; + /** Enable dry-run mode */ + dryRun?: boolean; + /** Custom logger */ + logger?: Logger; + /** Stop execution on first failure (default: true) */ + stopOnError?: boolean; +} + +export interface ExecutorEvents { + "task:start": [task: Task, index: number, total: number]; + "task:complete": [task: Task, result: PluginResult, index: number, total: number]; + "task:error": [task: Task, error: Error, index: number, total: number]; + "execution:start": [tasks: Task[], operation: Operation]; + "execution:complete": [results: TaskResult[]]; + log: [level: string, message: string]; +} + +// Known plugin keys that can appear in tasks +const PLUGIN_KEYS = [ + "docker", + "service", + "lineinfile", + "reverseproxy", + "file", + "copy", + "git", + "command", + "rm", + "get_url", + "unarchive", + "replace", + "desktop", +] as const; + +// ============================================================================= +// TaskExecutor Class +// ============================================================================= + +/** + * Executes task lists from module YAML files. + * Uses registered plugins to handle each task type. + */ +export class TaskExecutor extends EventEmitter { + private baseContext: Omit; + private stopOnError: boolean; + + constructor(options: ExecutorOptions = {}) { + super(); + + this.stopOnError = options.stopOnError ?? true; + this.baseContext = { + mock: options.mock ?? isMockMode(), + dryRun: options.dryRun ?? false, + logger: options.logger ?? this.createDefaultLogger(), + }; + } + + /** + * Create default logger that emits log events + */ + private createDefaultLogger(): Logger { + return { + debug: (msg: string) => this.emit("log", "debug", msg), + info: (msg: string) => this.emit("log", "info", msg), + warn: (msg: string) => this.emit("log", "warn", msg), + error: (msg: string) => this.emit("log", "error", msg), + }; + } + + /** + * Execute a list of tasks for a given operation + */ + async execute(tasks: Task[], operation: Operation): Promise { + const results: TaskResult[] = []; + const registry = getPluginRegistry(); + const context: ExecutionContext = { + ...this.baseContext, + operation, + }; + + this.emit("execution:start", tasks, operation); + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]!; + const taskName = this.getTaskName(task); + + this.emit("task:start", task, i, tasks.length); + + const start = performance.now(); + + try { + // Find the plugin key in the task + const pluginKey = this.findPluginKey(task); + if (!pluginKey) { + throw new Error(`No plugin key found in task: ${JSON.stringify(task)}`); + } + + const plugin = registry.get(pluginKey); + if (!plugin) { + throw new Error(`Plugin not found: ${pluginKey}`); + } + + // Extract params for the plugin + const params = (task as Record)[pluginKey]; + + // Execute the plugin + const result = await plugin.execute(params, context); + const duration = performance.now() - start; + + results.push({ task, result, duration }); + this.emit("task:complete", task, result, i, tasks.length); + + // Stop on failure if configured + if (!result.success && this.stopOnError) { + context.logger.error(`Task failed: ${taskName} - ${result.message}`); + break; + } + } catch (error) { + const duration = performance.now() - start; + const errorMessage = error instanceof Error ? error.message : String(error); + + const failResult: PluginResult = { + success: false, + message: errorMessage, + changed: false, + }; + + results.push({ task, result: failResult, duration }); + this.emit("task:error", task, error as Error, i, tasks.length); + + if (this.stopOnError) { + context.logger.error(`Task error: ${taskName} - ${errorMessage}`); + break; + } + } + } + + this.emit("execution:complete", results); + return results; + } + + /** + * Find the plugin key in a task object + */ + private findPluginKey(task: Task): string | null { + for (const key of PLUGIN_KEYS) { + if (key in task) { + return key; + } + } + return null; + } + + /** + * Get a human-readable task name + */ + private getTaskName(task: Task): string { + // Use explicit name if provided + if ("name" in task && typeof task.name === "string") { + return task.name; + } + + // Otherwise, generate from plugin key and params + const pluginKey = this.findPluginKey(task); + if (pluginKey) { + const params = (task as Record)[pluginKey] as Record; + const identifier = params.name || params.path || params.dest || params.hostname; + if (identifier) { + return `${pluginKey}: ${identifier}`; + } + return pluginKey; + } + + return "unknown task"; + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Execute tasks with default options + */ +export async function executeTasks( + tasks: Task[], + operation: Operation, + options?: ExecutorOptions, +): Promise { + const executor = new TaskExecutor(options); + return executor.execute(tasks, operation); +} + +/** + * Check if all task results were successful + */ +export function allSucceeded(results: TaskResult[]): boolean { + return results.every((r) => r.result.success); +} + +/** + * Get failed task results + */ +export function getFailures(results: TaskResult[]): TaskResult[] { + return results.filter((r) => !r.result.success); +} + +/** + * Get changed task results + */ +export function getChanges(results: TaskResult[]): TaskResult[] { + return results.filter((r) => r.result.changed); +} diff --git a/bun/src/core/mock-state.ts b/bun/src/core/mock-state.ts new file mode 100644 index 0000000..2478ba7 --- /dev/null +++ b/bun/src/core/mock-state.ts @@ -0,0 +1,429 @@ +/** + * Mock state management for testing plugins without Docker/systemd. + * Used when KATANA_MOCK=true environment variable is set. + */ + +// ============================================================================= +// Types +// ============================================================================= + +export interface MockContainerState { + name: string; + image: string; + ports: Record; + running: boolean; +} + +export interface MockServiceState { + name: string; + running: boolean; +} + +export interface MockFileState { + path: string; + type: "directory" | "file"; + content?: string; + mode?: string; +} + +export interface MockCertState { + initialized: boolean; + domainBase: string; + createdAt: string; +} + +// ============================================================================= +// MockState Class +// ============================================================================= + +/** + * In-memory state tracker for mock mode testing. + * Simulates Docker containers, systemd services, files, and line-in-file operations. + */ +export class MockState { + private containers: Map = new Map(); + private services: Map = new Map(); + private files: Map = new Map(); + private fileLines: Map> = new Map(); + private certState: MockCertState | null = null; + + private static instance: MockState | null = null; + + /** + * Get or create the singleton instance + */ + static getInstance(): MockState { + if (!MockState.instance) { + MockState.instance = new MockState(); + } + return MockState.instance; + } + + /** + * Reset singleton (useful for testing) + */ + static resetInstance(): void { + MockState.instance = null; + } + + /** + * Reset all state (useful between tests) + */ + reset(): void { + this.containers.clear(); + this.services.clear(); + this.files.clear(); + this.fileLines.clear(); + this.reverseProxies.clear(); + this.gitRepos.clear(); + this.certState = null; + } + + // ========================================================================= + // Container Management + // ========================================================================= + + /** + * Create a container (but don't start it) + */ + createContainer(name: string, image: string, ports: Record = {}): void { + this.containers.set(name, { + name, + image, + ports, + running: false, + }); + } + + /** + * Start a container. Returns true if state changed. + */ + startContainer(name: string): boolean { + const container = this.containers.get(name); + if (!container) { + return false; + } + if (container.running) { + return false; + } + container.running = true; + return true; + } + + /** + * Stop a container. Returns true if state changed. + */ + stopContainer(name: string): boolean { + const container = this.containers.get(name); + if (!container) { + return false; + } + if (!container.running) { + return false; + } + container.running = false; + return true; + } + + /** + * Remove a container. Returns true if it existed. + */ + removeContainer(name: string): boolean { + return this.containers.delete(name); + } + + /** + * Check if a container exists + */ + containerExists(name: string): boolean { + return this.containers.has(name); + } + + /** + * Check if a container is running + */ + containerRunning(name: string): boolean { + const container = this.containers.get(name); + return container?.running ?? false; + } + + /** + * Get container state + */ + getContainer(name: string): MockContainerState | undefined { + return this.containers.get(name); + } + + // ========================================================================= + // Service Management + // ========================================================================= + + /** + * Start a service + */ + startService(name: string): boolean { + const existing = this.services.get(name); + if (existing?.running) { + return false; + } + this.services.set(name, { name, running: true }); + return true; + } + + /** + * Stop a service + */ + stopService(name: string): boolean { + const existing = this.services.get(name); + if (!existing?.running) { + return false; + } + existing.running = false; + return true; + } + + /** + * Restart a service (always returns true for changed) + */ + restartService(name: string): boolean { + this.services.set(name, { name, running: true }); + return true; + } + + /** + * Check if a service is running + */ + serviceRunning(name: string): boolean { + return this.services.get(name)?.running ?? false; + } + + /** + * Check if a service exists (has been started at least once) + */ + serviceExists(name: string): boolean { + return this.services.has(name); + } + + // ========================================================================= + // File Management + // ========================================================================= + + /** + * Create a directory + */ + createDirectory(path: string): boolean { + if (this.files.has(path)) { + return false; + } + this.files.set(path, { path, type: "directory" }); + return true; + } + + /** + * Write a file + */ + writeFile(path: string, content: string, mode?: string): boolean { + const existing = this.files.get(path); + if (existing?.type === "file" && existing.content === content && existing.mode === mode) { + return false; + } + this.files.set(path, { path, type: "file", content, mode }); + return true; + } + + /** + * Remove a file or directory + */ + removeFile(path: string): boolean { + return this.files.delete(path); + } + + /** + * Check if a file or directory exists + */ + fileExists(path: string): boolean { + return this.files.has(path); + } + + /** + * Check if path is a directory + */ + isDirectory(path: string): boolean { + return this.files.get(path)?.type === "directory"; + } + + /** + * Get file state + */ + getFile(path: string): MockFileState | undefined { + return this.files.get(path); + } + + // ========================================================================= + // Line-in-File Management + // ========================================================================= + + /** + * Add a line to a file. Returns true if line was added. + */ + addLine(path: string, line: string): boolean { + let lines = this.fileLines.get(path); + if (!lines) { + lines = new Set(); + this.fileLines.set(path, lines); + } + if (lines.has(line)) { + return false; + } + lines.add(line); + return true; + } + + /** + * Remove a line from a file. Returns true if line was removed. + */ + removeLine(path: string, line: string): boolean { + const lines = this.fileLines.get(path); + if (!lines) { + return false; + } + return lines.delete(line); + } + + /** + * Check if a file contains a specific line + */ + hasLine(path: string, line: string): boolean { + const lines = this.fileLines.get(path); + return lines?.has(line) ?? false; + } + + /** + * Get all lines in a file + */ + getLines(path: string): string[] { + const lines = this.fileLines.get(path); + return lines ? Array.from(lines) : []; + } + + // ========================================================================= + // Reverse Proxy Management (nginx configs) + // ========================================================================= + + private reverseProxies: Map = new Map(); + + /** + * Add a reverse proxy config + */ + addReverseProxy(hostname: string, proxyPass?: string): boolean { + if (this.reverseProxies.has(hostname)) { + return false; + } + this.reverseProxies.set(hostname, { hostname, proxyPass }); + return true; + } + + /** + * Remove a reverse proxy config + */ + removeReverseProxy(hostname: string): boolean { + return this.reverseProxies.delete(hostname); + } + + /** + * Check if reverse proxy exists + */ + reverseProxyExists(hostname: string): boolean { + return this.reverseProxies.has(hostname); + } + + // ========================================================================= + // Git Repository Management + // ========================================================================= + + private gitRepos: Map = new Map(); + + /** + * Clone a git repository + */ + cloneRepo(repo: string, dest: string): boolean { + if (this.gitRepos.has(dest)) { + return false; + } + this.gitRepos.set(dest, { repo, dest }); + // Also mark the destination as a directory + this.createDirectory(dest); + return true; + } + + /** + * Check if a git repo exists at destination + */ + repoExists(dest: string): boolean { + return this.gitRepos.has(dest); + } + + // ========================================================================= + // Certificate Management + // ========================================================================= + + /** + * Initialize certificates for a domain + */ + initCerts(domainBase: string): void { + this.certState = { + initialized: true, + domainBase, + createdAt: new Date().toISOString(), + }; + } + + /** + * Check if certificates have been initialized + */ + hasCerts(): boolean { + return this.certState?.initialized ?? false; + } + + /** + * Get certificate state + */ + getCertState(): MockCertState | null { + return this.certState; + } + + /** + * Get mock certificate paths (for testing) + */ + getCertPaths(statePath: string): { + cert: string; + key: string; + rootCACert: string; + rootCAKey: string; + } { + return { + cert: `${statePath}/certs/wildcard.crt`, + key: `${statePath}/certs/wildcard.key`, + rootCACert: `${statePath}/certs/rootCA.crt`, + rootCAKey: `${statePath}/certs/rootCA.key`, + }; + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Get the singleton MockState instance + */ +export function getMockState(): MockState { + return MockState.getInstance(); +} + +/** + * Check if mock mode is enabled + */ +export function isMockMode(): boolean { + return process.env.KATANA_MOCK === "true"; +} diff --git a/bun/src/core/module-fetcher.ts b/bun/src/core/module-fetcher.ts new file mode 100644 index 0000000..640467c --- /dev/null +++ b/bun/src/core/module-fetcher.ts @@ -0,0 +1,186 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DEFAULT_USER_DATA_DIR, MODULES_SUBDIR } from "../types/config"; + +export interface FetchOptions { + /** GitHub repository URL */ + repo: string; + /** Git branch to use */ + branch: string; + /** Target directory for katana data (default: ~/.local/share/katana) */ + targetDir?: string; +} + +export interface FetchResult { + /** Whether the operation succeeded */ + success: boolean; + /** Path to the modules directory */ + modulesPath: string; + /** Human-readable message */ + message: string; + /** True if updated existing repo, false if fresh clone */ + isUpdate: boolean; +} + +/** + * Expand ~ to home directory + */ +function expandPath(path: string): string { + if (path.startsWith("~/")) { + return join(homedir(), path.slice(2)); + } + return path; +} + +/** + * Get the default katana data directory + */ +function getDefaultDataDir(): string { + return expandPath(DEFAULT_USER_DATA_DIR); +} + +/** + * ModuleFetcher handles cloning and updating modules from GitHub + * Uses sparse checkout to only fetch the modules/ directory + */ +export class ModuleFetcher { + /** + * Fetch or update modules from GitHub + * + * Uses sparse checkout to only download the modules/ directory, + * and shallow clone (--depth 1) for efficiency. + */ + async fetchModules(options: FetchOptions): Promise { + const katanaDir = options.targetDir ?? getDefaultDataDir(); + const modulesPath = join(katanaDir, MODULES_SUBDIR); + const gitDir = join(katanaDir, ".git"); + + // Check if already a git repo + const isGitRepo = existsSync(gitDir); + + if (isGitRepo) { + return this.updateModules(katanaDir, modulesPath, options); + } + return this.cloneModules(katanaDir, modulesPath, options); + } + + /** + * Clone modules using sparse checkout (only modules/ directory) + */ + private async cloneModules( + katanaDir: string, + modulesPath: string, + options: FetchOptions, + ): Promise { + try { + // Ensure parent directory exists + await Bun.$`mkdir -p ${katanaDir}`.quiet(); + + // Initialize git repo + await Bun.$`git -C ${katanaDir} init`.quiet(); + + // Add remote + await Bun.$`git -C ${katanaDir} remote add origin ${options.repo}`.quiet(); + + // Enable sparse checkout + await Bun.$`git -C ${katanaDir} config core.sparseCheckout true`.quiet(); + + // Configure sparse checkout to only get modules/ + const sparseCheckoutFile = join(katanaDir, ".git", "info", "sparse-checkout"); + await Bun.write(sparseCheckoutFile, `${MODULES_SUBDIR}/\n`); + + // Fetch with depth 1 (shallow clone) + await Bun.$`git -C ${katanaDir} fetch --depth 1 origin ${options.branch}`.quiet(); + + // Checkout the branch + await Bun.$`git -C ${katanaDir} checkout ${options.branch}`.quiet(); + + return { + success: true, + modulesPath, + message: `Modules cloned from ${options.repo} (branch: ${options.branch})`, + isUpdate: false, + }; + } catch (error) { + // Clean up on failure + try { + await Bun.$`rm -rf ${katanaDir}`.quiet(); + } catch { + // Ignore cleanup errors + } + + return { + success: false, + modulesPath, + message: error instanceof Error ? error.message : String(error), + isUpdate: false, + }; + } + } + + /** + * Update existing modules repo + */ + private async updateModules( + katanaDir: string, + modulesPath: string, + options: FetchOptions, + ): Promise { + try { + // Fetch latest from remote + await Bun.$`git -C ${katanaDir} fetch --depth 1 origin ${options.branch}`.quiet(); + + // Reset to latest (discards any local changes) + await Bun.$`git -C ${katanaDir} reset --hard origin/${options.branch}`.quiet(); + + return { + success: true, + modulesPath, + message: `Modules updated from ${options.repo} (branch: ${options.branch})`, + isUpdate: true, + }; + } catch { + // If update fails, try re-cloning + console.log("Update failed, attempting fresh clone..."); + + try { + await Bun.$`rm -rf ${katanaDir}`.quiet(); + } catch { + // Ignore cleanup errors + } + + return this.cloneModules(katanaDir, modulesPath, options); + } + } + + /** + * Check if a directory is a git repository + */ + isGitRepo(dir: string): boolean { + const gitDir = join(dir, ".git"); + return existsSync(gitDir); + } + + /** + * Get information about the current modules state + */ + async getModulesInfo(katanaDir: string): Promise<{ branch: string; commit: string } | null> { + const gitDir = join(katanaDir, ".git"); + if (!existsSync(gitDir)) { + return null; + } + + try { + const branch = await Bun.$`git -C ${katanaDir} rev-parse --abbrev-ref HEAD`.quiet().text(); + const commit = await Bun.$`git -C ${katanaDir} rev-parse --short HEAD`.quiet().text(); + + return { + branch: branch.trim(), + commit: commit.trim(), + }; + } catch { + return null; + } + } +} diff --git a/bun/src/core/module-loader.ts b/bun/src/core/module-loader.ts new file mode 100644 index 0000000..a9ab714 --- /dev/null +++ b/bun/src/core/module-loader.ts @@ -0,0 +1,574 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { parse as parseYaml, YAMLParseError } from "yaml"; +import { formatModuleError, type Module, type ModuleCategory, safeParseModule } from "../types"; +import { type Config, DEFAULT_USER_DATA_DIR, MODULES_SUBDIR } from "../types/config"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * A loaded module with additional metadata about its source + */ +export interface LoadedModule extends Module { + /** Absolute path to the source YAML file */ + sourcePath: string; + /** Directory containing the module file */ + sourceDir: string; +} + +/** + * Detailed error information for module loading failures + */ +export interface ModuleLoadError { + /** The file path that failed to load */ + filePath: string; + /** Type of error: 'yaml_parse', 'validation', 'file_read' */ + type: "yaml_parse" | "validation" | "file_read"; + /** Human-readable error message */ + message: string; + /** Line number if available (for YAML parse errors) */ + line?: number; + /** Column number if available */ + column?: number; + /** Raw error for debugging */ + cause?: unknown; +} + +/** + * Result of loading a single module - success or failure + */ +export interface ModuleLoadResult { + success: boolean; + module?: LoadedModule; + error?: ModuleLoadError; +} + +/** + * Result of loading all modules + */ +export interface ModuleLoaderResult { + /** Successfully loaded modules */ + modules: LoadedModule[]; + /** Errors encountered during loading */ + errors: ModuleLoadError[]; + /** Whether all modules loaded successfully */ + success: boolean; +} + +/** + * Options for the module loader + */ +export interface ModuleLoaderOptions { + /** Base directory for modules (default: ../modules relative to bun/) */ + modulesDir?: string; + /** Whether to fail on first error (default: false - collect all errors) */ + failFast?: boolean; + /** Optional filter by category */ + category?: ModuleCategory; + /** Whether to use cache (default: true) */ + useCache?: boolean; +} + +// ============================================================================= +// Custom Error for Missing Modules +// ============================================================================= + +export class ModulesNotFoundError extends Error { + constructor() { + super( + "Modules directory not found.\n\n" + + "Run 'katana update' to fetch modules from GitHub, or\n" + + "Set 'modulesPath' in your config file (~/.config/katana/config.yml), or\n" + + "Set KATANA_HOME environment variable.", + ); + this.name = "ModulesNotFoundError"; + } +} + +// ============================================================================= +// Path Resolution Helpers +// ============================================================================= + +/** + * Expand ~ to home directory in paths + */ +function expandPath(path: string): string { + if (path.startsWith("~/")) { + return join(homedir(), path.slice(2)); + } + return path; +} + +/** + * Check if a directory exists and contains files + */ +function directoryExists(path: string): boolean { + try { + return existsSync(path); + } catch { + return false; + } +} + +/** + * Get the default user modules directory + */ +function getDefaultUserModulesDir(): string { + return join(expandPath(DEFAULT_USER_DATA_DIR), MODULES_SUBDIR); +} + +/** + * Get the modules directory relative to the binary location + * Works for both compiled binary and development mode + */ +function getBinaryRelativeModulesDir(): string { + // Bun.main gives us the path to the main script/executable + const mainPath = Bun.main; + const binaryDir = dirname(mainPath); + + // For compiled binary in bin/, modules would be at ../modules + // For dev mode running src/cli.ts, go up to bun/ then to ../modules + const possiblePaths = [ + resolve(binaryDir, "..", MODULES_SUBDIR), // bin/katana -> modules/ + resolve(binaryDir, "..", "..", MODULES_SUBDIR), // src/cli.ts -> modules/ + resolve(binaryDir, "..", "..", "..", MODULES_SUBDIR), // src/core/module-loader.ts -> modules/ + ]; + + for (const path of possiblePaths) { + if (directoryExists(path)) { + return path; + } + } + + // Return the first one as default (will be checked again later) + // biome-ignore lint/style/noNonNullAssertion: We know the array has at least one element + return possiblePaths[0]!; +} + +// ============================================================================= +// ModuleLoader Class +// ============================================================================= + +export class ModuleLoader { + private modulesDir: string | null = null; + private configModulesPath?: string; + private cache: Map = new Map(); + private cacheTimestamp = 0; + private cacheTTL = 5000; // 5 seconds + + private static instance: ModuleLoader | null = null; + + constructor(modulesDir?: string, configModulesPath?: string) { + this.configModulesPath = configModulesPath; + // If explicit modulesDir provided, use it directly + if (modulesDir) { + this.modulesDir = modulesDir; + } + // Otherwise, resolve dynamically when needed + } + + /** + * Resolve modules directory with priority: + * 1. Explicit constructor parameter (already set in modulesDir) + * 2. Config file modulesPath (if set and directory exists) + * 3. KATANA_HOME/modules environment variable (if set and exists) + * 4. ~/.local/share/katana/modules (if exists) + * 5. ../modules relative to binary (for git clone scenario) + * 6. null - modules need to be fetched + */ + private resolveModulesDir(): string | null { + // Already resolved + if (this.modulesDir !== null) { + return this.modulesDir; + } + + // Priority 1: Config file modulesPath + if (this.configModulesPath) { + const expanded = expandPath(this.configModulesPath); + if (directoryExists(expanded)) { + this.modulesDir = expanded; + return this.modulesDir; + } + } + + // Priority 2: KATANA_HOME environment variable + const katanaHome = process.env.KATANA_HOME; + if (katanaHome) { + const envPath = join(katanaHome, MODULES_SUBDIR); + if (directoryExists(envPath)) { + this.modulesDir = envPath; + return this.modulesDir; + } + } + + // Priority 3: User's local share directory + const userModulesDir = getDefaultUserModulesDir(); + if (directoryExists(userModulesDir)) { + this.modulesDir = userModulesDir; + return this.modulesDir; + } + + // Priority 4: Relative to binary (for git clone + build scenario) + const binaryRelative = getBinaryRelativeModulesDir(); + if (directoryExists(binaryRelative)) { + this.modulesDir = binaryRelative; + return this.modulesDir; + } + + // No modules found + return null; + } + + /** + * Get the resolved modules directory + * @throws ModulesNotFoundError if modules not available + */ + getModulesDir(): string { + const dir = this.resolveModulesDir(); + if (!dir) { + throw new ModulesNotFoundError(); + } + return dir; + } + + /** + * Check if modules are available without throwing + */ + hasModules(): boolean { + return this.resolveModulesDir() !== null; + } + + /** + * Get or create the singleton instance + * @param config Optional config to use for modulesPath + */ + static getInstance(config?: Config): ModuleLoader { + if (!ModuleLoader.instance) { + ModuleLoader.instance = new ModuleLoader(undefined, config?.modulesPath); + } + return ModuleLoader.instance; + } + + /** + * Reset singleton (useful for testing and after module updates) + */ + static resetInstance(): void { + ModuleLoader.instance = null; + } + + /** + * Check if cache is still valid + */ + private isCacheValid(): boolean { + return Date.now() - this.cacheTimestamp < this.cacheTTL; + } + + /** + * Invalidate the cache + */ + invalidateCache(): void { + this.cache.clear(); + this.cacheTimestamp = 0; + } + + /** + * Discover all YAML files in the modules directory + * @throws ModulesNotFoundError if modules directory not available + */ + private async discoverModuleFiles(category?: ModuleCategory): Promise { + const modulesDir = this.getModulesDir(); // Throws if not available + const pattern = category ? `${category}/*.yml` : "**/*.yml"; + const glob = new Bun.Glob(pattern); + const files: string[] = []; + + for await (const file of glob.scan({ + cwd: modulesDir, + absolute: true, + onlyFiles: true, + })) { + files.push(file); + } + + return files; + } + + /** + * Create a file read error + */ + private createFileReadError(filePath: string, error: unknown): ModuleLoadError { + return { + filePath, + type: "file_read", + message: error instanceof Error ? error.message : `Failed to read file: ${filePath}`, + cause: error, + }; + } + + /** + * Create a YAML parse error with line/column info if available + */ + private createYamlParseError(filePath: string, error: unknown): ModuleLoadError { + if (error instanceof YAMLParseError) { + return { + filePath, + type: "yaml_parse", + message: error.message, + line: error.linePos?.[0]?.line, + column: error.linePos?.[0]?.col, + cause: error, + }; + } + return { + filePath, + type: "yaml_parse", + message: error instanceof Error ? error.message : String(error), + cause: error, + }; + } + + /** + * Create a validation error from Zod + */ + private createValidationError( + filePath: string, + zodError: import("zod").ZodError, + ): ModuleLoadError { + return { + filePath, + type: "validation", + message: formatModuleError(zodError), + cause: zodError, + }; + } + + /** + * Load a single module from a file path + */ + async loadFromFile(filePath: string): Promise { + try { + // Step 1: Read file using Bun.file + const file = Bun.file(filePath); + const exists = await file.exists(); + + if (!exists) { + return { + success: false, + error: this.createFileReadError(filePath, new Error("File not found")), + }; + } + + const content = await file.text(); + + // Step 2: Parse YAML + let parsed: unknown; + try { + parsed = parseYaml(content, { + prettyErrors: true, + }); + } catch (error) { + return { + success: false, + error: this.createYamlParseError(filePath, error), + }; + } + + // Step 3: Validate with Zod + const result = safeParseModule(parsed); + + if (!result.success) { + return { + success: false, + error: this.createValidationError(filePath, result.error), + }; + } + + // Step 4: Create LoadedModule with source metadata + const loadedModule: LoadedModule = { + ...result.data, + sourcePath: filePath, + sourceDir: dirname(filePath), + }; + + return { + success: true, + module: loadedModule, + }; + } catch (error) { + return { + success: false, + error: this.createFileReadError(filePath, error), + }; + } + } + + /** + * Load all modules from the modules directory + */ + async loadAll(options: ModuleLoaderOptions = {}): Promise { + const { failFast = false, category, useCache = true } = options; + + // Check cache + if (useCache && this.isCacheValid() && this.cache.size > 0) { + const cachedModules = Array.from(this.cache.values()); + const filtered = category + ? cachedModules.filter((m) => m.category === category) + : cachedModules; + return { + modules: filtered, + errors: [], + success: true, + }; + } + + // Discover files + const files = await this.discoverModuleFiles(category); + + const modules: LoadedModule[] = []; + const errors: ModuleLoadError[] = []; + + for (const filePath of files) { + const result = await this.loadFromFile(filePath); + + if (result.success && result.module) { + modules.push(result.module); + // Update cache + this.cache.set(result.module.name.toLowerCase(), result.module); + } else if (result.error) { + errors.push(result.error); + if (failFast) { + break; + } + } + } + + // Update cache timestamp + this.cacheTimestamp = Date.now(); + + return { + modules, + errors, + success: errors.length === 0, + }; + } + + /** + * Load a single module by name (case-insensitive) + */ + async loadByName(name: string): Promise { + const normalizedName = name.toLowerCase(); + + // Check cache first + if (this.cache.has(normalizedName) && this.isCacheValid()) { + return { + success: true, + module: this.cache.get(normalizedName), + }; + } + + // Load all to populate cache, then retrieve + await this.loadAll(); + + const module = this.cache.get(normalizedName); + if (module) { + return { success: true, module }; + } + + return { + success: false, + error: { + filePath: "", + type: "file_read", + message: `Module not found: ${name}`, + }, + }; + } + + /** + * Get all module names (useful for CLI tab completion) + */ + async getModuleNames(): Promise { + const result = await this.loadAll(); + return result.modules.map((m) => m.name); + } + + /** + * Get modules grouped by category + */ + async getModulesByCategory(): Promise> { + const result = await this.loadAll(); + const byCategory = new Map(); + + for (const module of result.modules) { + const existing = byCategory.get(module.category) ?? []; + existing.push(module); + byCategory.set(module.category, existing); + } + + return byCategory; + } + + /** + * Validate a YAML file without caching + */ + async validateFile(filePath: string): Promise { + return this.loadFromFile(filePath); + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Load all modules using the singleton instance + */ +export async function loadAllModules(options?: ModuleLoaderOptions): Promise { + return ModuleLoader.getInstance().loadAll(options); +} + +/** + * Load a single module by name using the singleton instance + */ +export async function loadModule(name: string): Promise { + return ModuleLoader.getInstance().loadByName(name); +} + +/** + * Validate a module YAML file + */ +export async function validateModuleFile(filePath: string): Promise { + return ModuleLoader.getInstance().validateFile(filePath); +} + +// ============================================================================= +// Error Formatting +// ============================================================================= + +/** + * Format a ModuleLoadError into a human-readable string for CLI output + */ +export function formatModuleLoadError(error: ModuleLoadError): string { + const location = error.line + ? ` at line ${error.line}${error.column ? `:${error.column}` : ""}` + : ""; + + const typeLabel = { + yaml_parse: "YAML Parse Error", + validation: "Validation Error", + file_read: "File Read Error", + }[error.type]; + + return `${typeLabel} in ${error.filePath}${location}:\n ${error.message}`; +} + +/** + * Format all errors from a ModuleLoaderResult + */ +export function formatModuleLoaderErrors(result: ModuleLoaderResult): string { + if (result.errors.length === 0) return ""; + + return result.errors.map(formatModuleLoadError).join("\n\n"); +} diff --git a/bun/src/core/state-manager.ts b/bun/src/core/state-manager.ts new file mode 100644 index 0000000..fe80371 --- /dev/null +++ b/bun/src/core/state-manager.ts @@ -0,0 +1,319 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as yamlParse, stringify as yamlStringify } from "yaml"; +import { + type InstalledModule, + type InstalledState, + InstalledStateSchema, + LockFileYamlSchema, + type LockState, +} from "../types/state"; +import { ModuleStatus } from "../types/status"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface LockOptions { + /** Who is enabling the lock (defaults to USER env var or "unknown") */ + lockedBy?: string; + /** Optional message explaining why lock is enabled */ + message?: string; +} + +export interface StateManagerOptions { + /** Base directory for state files (default: ~/.local/share/katana/) */ + stateDir?: string; +} + +// ============================================================================= +// StateManager Class +// ============================================================================= + +export class StateManager { + private stateDir: string; + private installedPath: string; + private lockPath: string; + + private static instance: StateManager | null = null; + + constructor(options?: StateManagerOptions) { + this.stateDir = options?.stateDir ?? this.resolveDefaultStateDir(); + this.installedPath = join(this.stateDir, "installed.yml"); + this.lockPath = join(this.stateDir, "katana.lock"); + } + + /** + * Resolve the default state directory + */ + private resolveDefaultStateDir(): string { + return join(homedir(), ".local", "share", "katana"); + } + + /** + * Get or create the singleton instance + */ + static getInstance(options?: StateManagerOptions): StateManager { + if (!StateManager.instance) { + StateManager.instance = new StateManager(options); + } + return StateManager.instance; + } + + /** + * Reset singleton (useful for testing) + */ + static resetInstance(): void { + StateManager.instance = null; + } + + /** + * Get the state directory path + */ + getStateDir(): string { + return this.stateDir; + } + + // ========================================================================= + // State Directory Management + // ========================================================================= + + /** + * Ensure the state directory exists + */ + async ensureStateDir(): Promise { + const file = Bun.file(this.stateDir); + if (!(await file.exists())) { + await Bun.$`mkdir -p ${this.stateDir}`.quiet(); + } + } + + /** + * Atomic write: write to temp file, then rename + */ + private async atomicWrite(path: string, content: string): Promise { + await this.ensureStateDir(); + const tempPath = `${path}.tmp.${Date.now()}`; + await Bun.write(tempPath, content); + await Bun.$`mv ${tempPath} ${path}`.quiet(); + } + + // ========================================================================= + // Installed Modules Management + // ========================================================================= + + /** + * Get current installed state (reads from file) + */ + async getInstalledState(): Promise { + const file = Bun.file(this.installedPath); + const exists = await file.exists(); + + if (!exists) { + // Return a fresh copy to prevent mutation of shared state + return { modules: {} }; + } + + try { + const content = await file.text(); + const parsed = yamlParse(content); + const result = InstalledStateSchema.safeParse(parsed); + + if (result.success) { + return result.data; + } + + // If parsing fails, return empty state + console.warn(`Warning: Invalid installed.yml format, treating as empty`); + return { modules: {} }; + } catch { + // File read or YAML parse error + return { modules: {} }; + } + } + + /** + * Save installed state to file + */ + private async saveInstalledState(state: InstalledState): Promise { + const content = yamlStringify(state); + await this.atomicWrite(this.installedPath, content); + } + + /** + * Check if a specific module is installed + */ + async isModuleInstalled(moduleName: string): Promise { + const state = await this.getInstalledState(); + const normalizedName = moduleName.toLowerCase(); + return normalizedName in state.modules; + } + + /** + * Mark a module as installed + */ + async installModule(moduleName: string, version?: string): Promise { + const state = await this.getInstalledState(); + const normalizedName = moduleName.toLowerCase(); + + const moduleInfo: InstalledModule = { + installedAt: new Date().toISOString(), + }; + if (version) { + moduleInfo.version = version; + } + + state.modules[normalizedName] = moduleInfo; + await this.saveInstalledState(state); + } + + /** + * Remove a module from installed state + */ + async removeModule(moduleName: string): Promise { + const state = await this.getInstalledState(); + const normalizedName = moduleName.toLowerCase(); + + delete state.modules[normalizedName]; + await this.saveInstalledState(state); + } + + /** + * Get list of all installed module names + */ + async getInstalledModuleNames(): Promise { + const state = await this.getInstalledState(); + return Object.keys(state.modules); + } + + /** + * Get installation metadata for a module + */ + async getModuleInstallInfo(moduleName: string): Promise { + const state = await this.getInstalledState(); + const normalizedName = moduleName.toLowerCase(); + return state.modules[normalizedName] ?? null; + } + + // ========================================================================= + // Lock Mode Management + // ========================================================================= + + /** + * Get current lock state (handles both legacy and YAML formats) + */ + async getLockState(): Promise { + const file = Bun.file(this.lockPath); + + if (!(await file.exists())) { + // Return a fresh copy to prevent mutation of shared state + return { locked: false, modules: [] }; + } + + try { + const content = await file.text(); + + // Try to parse as YAML first + try { + const parsed = yamlParse(content); + + // Check if it's the new YAML format (has 'locked' key) + if (parsed && typeof parsed === "object" && "locked" in parsed) { + const result = LockFileYamlSchema.safeParse(parsed); + if (result.success) { + return result.data; + } + } + } catch { + // Not valid YAML, try legacy format + } + + // Try legacy format: newline-separated module names + const modules = content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (modules.length > 0) { + return { + locked: true, + modules, + }; + } + + return { locked: false, modules: [] }; + } catch { + return { locked: false, modules: [] }; + } + } + + /** + * Check if system is currently locked + */ + async isLocked(): Promise { + const state = await this.getLockState(); + return state.locked; + } + + /** + * Enable lock mode, capturing current installed modules + */ + async enableLock(options?: LockOptions): Promise { + const installedModules = await this.getInstalledModuleNames(); + + const lockState: LockState = { + locked: true, + modules: installedModules, + lockedAt: new Date().toISOString(), + lockedBy: options?.lockedBy ?? process.env.USER ?? "unknown", + message: options?.message, + }; + + const content = yamlStringify(lockState); + await this.atomicWrite(this.lockPath, content); + } + + /** + * Disable lock mode + */ + async disableLock(): Promise { + const file = Bun.file(this.lockPath); + + if (await file.exists()) { + await Bun.$`rm ${this.lockPath}`.quiet(); + } + } + + /** + * Get modules that were installed when lock was enabled + */ + async getLockedModules(): Promise { + const state = await this.getLockState(); + return state.modules; + } + + // ========================================================================= + // Module Status + // ========================================================================= + + /** + * Get the status of a module using ModuleStatus enum. + * Returns NOT_INSTALLED or INSTALLED based on the state file. + * For running/stopped status, use StatusChecker which performs live checks. + */ + async getModuleStatus(moduleName: string): Promise { + const isInstalled = await this.isModuleInstalled(moduleName); + return isInstalled ? ModuleStatus.INSTALLED : ModuleStatus.NOT_INSTALLED; + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Get the singleton StateManager instance + */ +export function getStateManager(options?: StateManagerOptions): StateManager { + return StateManager.getInstance(options); +} diff --git a/bun/src/core/status.ts b/bun/src/core/status.ts new file mode 100644 index 0000000..98dd6ab --- /dev/null +++ b/bun/src/core/status.ts @@ -0,0 +1,232 @@ +import { PluginRegistry } from "../plugins/registry"; +import type { ExistsCheck, StartedCheck } from "../types/module"; +import { ModuleStatus } from "../types/status"; +import type { LoadedModule } from "./module-loader"; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Result of a status check for a single module + */ +export interface StatusResult { + /** Overall status (derived from installed + running) */ + status: ModuleStatus; + /** Whether the module is installed */ + installed: boolean; + /** Whether the module is running */ + running: boolean; + /** Timestamp when the check was performed */ + checkedAt: number; +} + +/** + * Options for the StatusChecker + */ +export interface StatusCheckerOptions { + /** Cache TTL in milliseconds (default: 5000) */ + cacheTTL?: number; +} + +// ============================================================================= +// StatusChecker Class +// ============================================================================= + +/** + * Checks module status by executing exists/started checks via plugins. + * + * Features: + * - Execute exists checks for installed status + * - Execute started checks for running status + * - Result caching with configurable TTL + * - Batch checking for multiple modules in parallel + */ +export class StatusChecker { + private cache: Map = new Map(); + private cacheTTL: number; + private registry: PluginRegistry; + private pluginsLoaded = false; + + constructor(options: StatusCheckerOptions = {}) { + this.cacheTTL = options.cacheTTL ?? 5000; + this.registry = PluginRegistry.getInstance(); + } + + /** + * Ensure plugins are loaded before checking status + */ + private async ensurePluginsLoaded(): Promise { + if (!this.pluginsLoaded) { + await this.registry.loadBuiltinPlugins(); + this.pluginsLoaded = true; + } + } + + /** + * Check status of a single module. + * Returns cached result if still valid. + */ + async checkStatus(module: LoadedModule): Promise { + const cacheKey = module.name.toLowerCase(); + const cached = this.cache.get(cacheKey); + + // Return cached if valid + if (cached && Date.now() - cached.checkedAt < this.cacheTTL) { + return cached; + } + + await this.ensurePluginsLoaded(); + + let installed = false; + let running = false; + + // Check installed status via exists check + if (module.status?.installed?.exists) { + installed = await this.executeExistsCheck(module.status.installed.exists); + } + + // Check running status via started check + if (module.status?.running?.started) { + running = await this.executeStartedCheck(module.status.running.started); + } + + // Determine final status using hierarchy + // If not installed, can't be running + // If installed and running -> RUNNING + // If installed and not running -> STOPPED (or INSTALLED if no running check) + // If not installed -> NOT_INSTALLED + let status: ModuleStatus; + if (!installed) { + status = ModuleStatus.NOT_INSTALLED; + running = false; // Can't be running if not installed + } else if (running) { + status = ModuleStatus.RUNNING; + } else if (module.status?.running?.started) { + // Has a running check but it returned false -> STOPPED + status = ModuleStatus.STOPPED; + } else { + // No running check defined -> just INSTALLED + status = ModuleStatus.INSTALLED; + } + + const result: StatusResult = { + status, + installed, + running, + checkedAt: Date.now(), + }; + + this.cache.set(cacheKey, result); + return result; + } + + /** + * Check status of multiple modules in parallel. + * Returns a map of module name (lowercase) to status result. + */ + async checkStatusBatch(modules: LoadedModule[]): Promise> { + await this.ensurePluginsLoaded(); + + const results = new Map(); + const promises = modules.map(async (module) => { + const result = await this.checkStatus(module); + results.set(module.name.toLowerCase(), result); + }); + + await Promise.all(promises); + return results; + } + + /** + * Clear the cache + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Execute an exists check using the appropriate plugin. + * + * The ExistsCheck object has optional fields for each plugin type: + * - docker: container name + * - service: service name + * - path: file/directory path + */ + private async executeExistsCheck(check: ExistsCheck): Promise { + try { + if (check.docker) { + const plugin = this.registry.get("docker"); + if (plugin?.exists) { + return await plugin.exists({ name: check.docker }); + } + } + + if (check.service) { + const plugin = this.registry.get("service"); + if (plugin?.exists) { + return await plugin.exists({ name: check.service, state: "running" }); + } + } + + if (check.path) { + const plugin = this.registry.get("file"); + if (plugin?.exists) { + return await plugin.exists({ path: check.path, state: "directory" }); + } + } + + return false; + } catch { + return false; + } + } + + /** + * Execute a started check using the appropriate plugin. + * + * The StartedCheck object has optional fields for each plugin type: + * - docker: container name + * - service: service name + */ + private async executeStartedCheck(check: StartedCheck): Promise { + try { + if (check.docker) { + const plugin = this.registry.get("docker"); + if (plugin?.started) { + return await plugin.started({ name: check.docker }); + } + } + + if (check.service) { + const plugin = this.registry.get("service"); + if (plugin?.started) { + return await plugin.started({ name: check.service, state: "running" }); + } + } + + return false; + } catch { + return false; + } + } + + /** + * Format status result for display. + * Returns strings like "installed, running" or "not installed" + */ + static formatStatus(result: StatusResult): string { + if (!result.installed) { + return "not installed"; + } + + const parts: string[] = ["installed"]; + if (result.running) { + parts.push("running"); + } else if (result.status === ModuleStatus.STOPPED) { + parts.push("stopped"); + } + + return parts.join(", "); + } +} diff --git a/bun/src/plugins/command.ts b/bun/src/plugins/command.ts new file mode 100644 index 0000000..1e67977 --- /dev/null +++ b/bun/src/plugins/command.ts @@ -0,0 +1,143 @@ +/** + * Command plugin for running shell commands. + */ + +import { isMockMode } from "../core/mock-state"; +import { CommandParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class CommandPlugin extends BasePlugin { + readonly name = "command"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = CommandParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid command params: ${parsed.error.message}`); + } + + const { cmd, cwd, unsafe, shell } = parsed.data; + + // Safety check - require unsafe flag for potentially dangerous commands + if (!unsafe && this.isDangerous(cmd)) { + return this.failure(`Command appears dangerous. Set unsafe: true to execute: ${cmd}`); + } + + // Mock mode - just log and succeed + if (context.mock || isMockMode()) { + context.logger.info(`[mock] Would run: ${cmd}`); + return this.success(`[mock] ${cmd}`); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] command: ${cmd}`); + return this.noop(`Would run: ${cmd}`); + } + + // Real execution + return this.executeReal(cmd, cwd, shell ?? false, context); + } + + /** + * Check if command appears dangerous + */ + private isDangerous(cmd: string): boolean { + const dangerousPatterns = [ + /\brm\s+-rf?\s+\//, // rm -r / + /\brm\s+-rf?\s+\*/, // rm -r * + /\bdd\s+.*of=\/dev\//, // dd to device + /\bmkfs/, // format filesystem + /\b:\(\)\s*\{/, // fork bomb + /\bchmod\s+-R\s+777\s+\//, // chmod 777 / + /\bchown\s+-R\s+.*\s+\/\s*$/, // chown -R / + ]; + + return dangerousPatterns.some((pattern) => pattern.test(cmd)); + } + + /** + * Execute real command + */ + private async executeReal( + cmd: string, + cwd: string | undefined, + shell: boolean, + context: ExecutionContext, + ): Promise { + try { + context.logger.info(`Running: ${cmd}`); + + let proc: ReturnType; + + if (shell) { + // Run through shell + proc = Bun.spawn(["sh", "-c", cmd], { + stdout: "pipe", + stderr: "pipe", + cwd: cwd, + }); + } else { + // Parse command into args + const args = this.parseCommand(cmd); + proc = Bun.spawn(args, { + stdout: "pipe", + stderr: "pipe", + cwd: cwd, + }); + } + + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stderr = await new Response(proc.stderr as ReadableStream).text(); + + if (exitCode !== 0) { + const error = stderr.trim() || stdout.trim() || `Exit code: ${exitCode}`; + return this.failure(`Command failed: ${error}`); + } + + return this.success(`Command completed: ${cmd}`); + } catch (error) { + return this.failure( + `Command failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Parse a command string into arguments. + * Handles simple quoting but for complex commands use shell: true + */ + private parseCommand(cmd: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote = false; + let quoteChar = ""; + + for (const char of cmd) { + if (inQuote) { + if (char === quoteChar) { + inQuote = false; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = true; + quoteChar = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; + } +} diff --git a/bun/src/plugins/copy.ts b/bun/src/plugins/copy.ts new file mode 100644 index 0000000..66980b3 --- /dev/null +++ b/bun/src/plugins/copy.ts @@ -0,0 +1,114 @@ +/** + * Copy plugin for writing content to files. + * Creates files with specified content and optional mode. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { CopyParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class CopyPlugin extends BasePlugin { + readonly name = "copy"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = CopyParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid copy params: ${parsed.error.message}`); + } + + const { dest, content, mode } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(dest, content, mode, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] copy: write to ${dest}`); + return this.noop(`Would write content to ${dest}`); + } + + // Real execution + return this.executeReal(dest, content, mode, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + dest: string, + content: string, + mode: string | undefined, + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + const changed = mock.writeFile(dest, content, mode); + + if (!changed) { + return this.noop(`File ${dest} already has the same content`); + } + + context.logger.info(`[mock] Wrote content to: ${dest}`); + return this.success(`Wrote content to ${dest}`); + } + + /** + * Execute real file operations + */ + private async executeReal( + dest: string, + content: string, + mode: string | undefined, + context: ExecutionContext, + ): Promise { + try { + const file = Bun.file(dest); + + // Check if content is the same (idempotent) + if (await file.exists()) { + const existingContent = await file.text(); + if (existingContent === content) { + return this.noop(`File ${dest} already has the same content`); + } + } + + // Ensure parent directory exists + const parentDir = dest.substring(0, dest.lastIndexOf("/")); + if (parentDir) { + await Bun.$`mkdir -p ${parentDir}`.quiet(); + } + + // Write content + await Bun.write(dest, content); + + // Set mode if specified + if (mode) { + await Bun.$`chmod ${mode} ${dest}`.quiet(); + } + + context.logger.info(`Wrote content to: ${dest}`); + return this.success(`Wrote content to ${dest}`); + } catch (error) { + return this.failure(`copy failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Check if file exists + */ + async exists(params: unknown): Promise { + const parsed = CopyParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().fileExists(parsed.data.dest); + } + + const file = Bun.file(parsed.data.dest); + return file.exists(); + } +} diff --git a/bun/src/plugins/desktop.ts b/bun/src/plugins/desktop.ts new file mode 100644 index 0000000..6bbd956 --- /dev/null +++ b/bun/src/plugins/desktop.ts @@ -0,0 +1,245 @@ +/** + * Desktop plugin for managing .desktop files and favorites. + * Creates menu items and optionally adds to desktop favorites. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { getMockState, isMockMode } from "../core/mock-state"; +import { DesktopParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +const APPLICATIONS_DIR = join(homedir(), ".local", "share", "applications"); + +export class DesktopPlugin extends BasePlugin { + readonly name = "desktop"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = DesktopParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid desktop params: ${parsed.error.message}`); + } + + const { desktop_file, filename } = parsed.data; + + // Determine action from operation context + const isRemove = context.operation === "remove"; + + // For remove, we need the filename + if (isRemove) { + const removeFilename = filename || desktop_file?.filename; + if (!removeFilename) { + return this.failure("filename required for remove operation"); + } + return this.executeRemove(removeFilename, context); + } + + // For install, we need desktop_file + if (!desktop_file) { + return this.failure("desktop_file required for install operation"); + } + + return this.executeInstall( + desktop_file.filename, + desktop_file.content, + desktop_file.add_to_favorites ?? false, + context, + ); + } + + /** + * Execute install operation + */ + private async executeInstall( + filename: string, + content: string, + addToFavorites: boolean, + context: ExecutionContext, + ): Promise { + const desktopPath = join(APPLICATIONS_DIR, filename); + + // Mock mode + if (context.mock || isMockMode()) { + const mock = getMockState(); + if (mock.fileExists(desktopPath)) { + return this.noop(`Desktop file ${filename} already exists`); + } + mock.writeFile(desktopPath, content); + context.logger.info(`[mock] Created desktop file: ${filename}`); + return this.success(`Created desktop file ${filename}`); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] desktop install: ${filename}`); + return this.noop(`Would create desktop file ${filename}`); + } + + // Real execution + try { + const file = Bun.file(desktopPath); + + // Check if already exists with same content (idempotent) + if (await file.exists()) { + const existingContent = await file.text(); + if (existingContent === content) { + return this.noop(`Desktop file ${filename} already exists`); + } + } + + // Ensure applications directory exists + await Bun.$`mkdir -p ${APPLICATIONS_DIR}`.quiet(); + + // Write desktop file + await Bun.write(desktopPath, content); + + // Add to favorites if requested + if (addToFavorites) { + await this.addToFavorites(filename, context); + } + + context.logger.info(`Created desktop file: ${filename}`); + return this.success(`Created desktop file ${filename}`); + } catch (error) { + return this.failure( + `Desktop file creation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Execute remove operation + */ + private async executeRemove(filename: string, context: ExecutionContext): Promise { + const desktopPath = join(APPLICATIONS_DIR, filename); + + // Mock mode + if (context.mock || isMockMode()) { + const mock = getMockState(); + if (!mock.fileExists(desktopPath)) { + return this.noop(`Desktop file ${filename} does not exist`); + } + mock.removeFile(desktopPath); + context.logger.info(`[mock] Removed desktop file: ${filename}`); + return this.success(`Removed desktop file ${filename}`); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] desktop remove: ${filename}`); + return this.noop(`Would remove desktop file ${filename}`); + } + + // Real execution + try { + const file = Bun.file(desktopPath); + + if (!(await file.exists())) { + return this.noop(`Desktop file ${filename} does not exist`); + } + + // Remove from favorites first + await this.removeFromFavorites(filename, context); + + // Remove desktop file + await Bun.$`rm ${desktopPath}`.quiet(); + + context.logger.info(`Removed desktop file: ${filename}`); + return this.success(`Removed desktop file ${filename}`); + } catch (error) { + return this.failure( + `Desktop file removal failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Add to GNOME favorites + */ + private async addToFavorites(filename: string, context: ExecutionContext): Promise { + try { + // Get current favorites + const proc = Bun.spawn(["gsettings", "get", "org.gnome.shell", "favorite-apps"], { + stdout: "pipe", + }); + const output = await new Response(proc.stdout).text(); + + // Parse the current favorites array + const currentFavorites = output.trim(); + const appId = filename; + + // Check if already in favorites + if (currentFavorites.includes(appId)) { + return; + } + + // Add to favorites + const newFavorites = currentFavorites.replace(/\]$/, `, '${appId}']`); + await Bun.spawn(["gsettings", "set", "org.gnome.shell", "favorite-apps", newFavorites], { + stdout: "pipe", + }).exited; + + context.logger.info(`Added ${filename} to favorites`); + } catch { + // Silently ignore favorites errors (gsettings may not be available) + } + } + + /** + * Remove from GNOME favorites + */ + private async removeFromFavorites(filename: string, context: ExecutionContext): Promise { + try { + // Get current favorites + const proc = Bun.spawn(["gsettings", "get", "org.gnome.shell", "favorite-apps"], { + stdout: "pipe", + }); + const output = await new Response(proc.stdout).text(); + + const appId = filename; + + // Check if in favorites + if (!output.includes(appId)) { + return; + } + + // Remove from favorites using regex + const newFavorites = output + .replace(new RegExp(`'${appId}',?\\s*`), "") + .replace(/,\s*\]/, "]"); // Clean up trailing comma + + await Bun.spawn(["gsettings", "set", "org.gnome.shell", "favorite-apps", newFavorites], { + stdout: "pipe", + }).exited; + + context.logger.info(`Removed ${filename} from favorites`); + } catch { + // Silently ignore favorites errors (gsettings may not be available) + } + } + + /** + * Check if desktop file exists + */ + async exists(params: unknown): Promise { + const parsed = DesktopParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + const filename = parsed.data.filename || parsed.data.desktop_file?.filename; + if (!filename) { + return false; + } + + const desktopPath = join(APPLICATIONS_DIR, filename); + + if (isMockMode()) { + return getMockState().fileExists(desktopPath); + } + + const file = Bun.file(desktopPath); + return file.exists(); + } +} diff --git a/bun/src/plugins/docker.ts b/bun/src/plugins/docker.ts new file mode 100644 index 0000000..b53841a --- /dev/null +++ b/bun/src/plugins/docker.ts @@ -0,0 +1,315 @@ +/** + * Docker plugin for managing containers. + * Supports pull, run, start, stop, and rm operations. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { DockerParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +type DockerAction = "pull" | "run" | "start" | "stop" | "rm"; + +export class DockerPlugin extends BasePlugin { + readonly name = "docker"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = DockerParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid docker params: ${parsed.error.message}`); + } + + const { name, image, ports } = parsed.data; + const action = this.inferAction(context.operation, !!image); + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(action, name, image, ports, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] docker ${action}: ${name}`); + return this.noop(`Would ${action} container ${name}`); + } + + // Real execution + return this.executeReal(action, name, image, ports, context); + } + + /** + * Infer the docker action based on operation context and params + */ + private inferAction(operation: string, hasImage: boolean): DockerAction { + switch (operation) { + case "remove": + return "rm"; + case "stop": + return "stop"; + case "start": + return hasImage ? "run" : "start"; + case "install": + default: + return hasImage ? "run" : "start"; + } + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + action: DockerAction, + name: string, + image: string | undefined, + ports: Record | undefined, + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + switch (action) { + case "pull": + context.logger.info(`[mock] Pulled image: ${image}`); + return this.success(`Pulled ${image}`); + + case "run": { + // Idempotent: if container exists and running, noop + if (mock.containerExists(name)) { + if (mock.containerRunning(name)) { + return this.noop(`Container ${name} already running`); + } + // Exists but not running, start it + mock.startContainer(name); + return this.success(`Started existing container ${name}`); + } + // Create and start + mock.createContainer(name, image ?? "unknown", ports ?? {}); + mock.startContainer(name); + context.logger.info(`[mock] Created and started container: ${name}`); + return this.success(`Created and started container ${name}`); + } + + case "start": { + if (!mock.containerExists(name)) { + return this.failure(`Container ${name} does not exist`); + } + if (mock.containerRunning(name)) { + return this.noop(`Container ${name} already running`); + } + mock.startContainer(name); + context.logger.info(`[mock] Started container: ${name}`); + return this.success(`Started container ${name}`); + } + + case "stop": { + if (!mock.containerExists(name)) { + return this.noop(`Container ${name} does not exist`); + } + if (!mock.containerRunning(name)) { + return this.noop(`Container ${name} not running`); + } + mock.stopContainer(name); + context.logger.info(`[mock] Stopped container: ${name}`); + return this.success(`Stopped container ${name}`); + } + + case "rm": { + if (!mock.containerExists(name)) { + return this.noop(`Container ${name} does not exist`); + } + // Stop first if running + if (mock.containerRunning(name)) { + mock.stopContainer(name); + } + mock.removeContainer(name); + context.logger.info(`[mock] Removed container: ${name}`); + return this.success(`Removed container ${name}`); + } + + default: + return this.failure(`Unknown docker action: ${action}`); + } + } + + /** + * Execute real docker commands + */ + private async executeReal( + action: DockerAction, + name: string, + image: string | undefined, + ports: Record | undefined, + context: ExecutionContext, + ): Promise { + switch (action) { + case "pull": + if (!image) { + return this.failure("Image required for pull"); + } + return this.runDocker(["pull", image], context); + + case "run": { + if (!image) { + return this.failure("Image required for run"); + } + + // Idempotent: check if container exists + if (await this.containerExists(name)) { + if (await this.containerRunning(name)) { + return this.noop(`Container ${name} already running`); + } + // Exists but not running, start it + return this.runDocker(["start", name], context); + } + + // Build docker run command + const args = ["run", "-d", "--name", name]; + if (ports) { + for (const [containerPort, hostPort] of Object.entries(ports)) { + // Handle port format like "80/tcp" -> "80" + const port = containerPort.replace("/tcp", "").replace("/udp", ""); + args.push("-p", `${hostPort}:${port}`); + } + } + args.push(image); + return this.runDocker(args, context); + } + + case "start": { + if (await this.containerRunning(name)) { + return this.noop(`Container ${name} already running`); + } + if (!(await this.containerExists(name))) { + return this.failure(`Container ${name} does not exist`); + } + return this.runDocker(["start", name], context); + } + + case "stop": { + if (!(await this.containerExists(name))) { + return this.noop(`Container ${name} does not exist`); + } + if (!(await this.containerRunning(name))) { + return this.noop(`Container ${name} not running`); + } + return this.runDocker(["stop", name], context); + } + + case "rm": { + if (!(await this.containerExists(name))) { + return this.noop(`Container ${name} does not exist`); + } + // Stop first if running + if (await this.containerRunning(name)) { + const stopResult = await this.runDocker(["stop", name], context); + if (!stopResult.success) { + return stopResult; + } + } + return this.runDocker(["rm", name], context); + } + + default: + return this.failure(`Unknown docker action: ${action}`); + } + } + + /** + * Run a docker command and return result + */ + private async runDocker(args: string[], context: ExecutionContext): Promise { + try { + context.logger.info(`docker ${args.join(" ")}`); + + const proc = Bun.spawn(["docker", ...args], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + return this.failure(`docker ${args[0]} failed: ${stderr.trim()}`); + } + + return this.success(`docker ${args.join(" ")}`); + } catch (error) { + return this.failure( + `Docker command failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if a container exists + */ + async exists(params: unknown): Promise { + const parsed = DockerParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().containerExists(parsed.data.name); + } + + return this.containerExists(parsed.data.name); + } + + /** + * Check if a container is running + */ + async started(params: unknown): Promise { + const parsed = DockerParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().containerRunning(parsed.data.name); + } + + return this.containerRunning(parsed.data.name); + } + + /** + * Check if a container exists (real docker) + */ + private async containerExists(name: string): Promise { + try { + const proc = Bun.spawn( + ["docker", "ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"], + { stdout: "pipe" }, + ); + const output = await new Response(proc.stdout).text(); + return output.trim() === name; + } catch { + return false; + } + } + + /** + * Check if a container is running (real docker) + */ + private async containerRunning(name: string): Promise { + try { + const proc = Bun.spawn( + [ + "docker", + "ps", + "--filter", + `name=^${name}$`, + "--filter", + "status=running", + "--format", + "{{.Names}}", + ], + { stdout: "pipe" }, + ); + const output = await new Response(proc.stdout).text(); + return output.trim() === name; + } catch { + return false; + } + } +} diff --git a/bun/src/plugins/file.ts b/bun/src/plugins/file.ts new file mode 100644 index 0000000..995f0d8 --- /dev/null +++ b/bun/src/plugins/file.ts @@ -0,0 +1,132 @@ +/** + * File plugin for managing directories. + * Creates directories or removes files/directories. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { FileParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class FilePlugin extends BasePlugin { + readonly name = "file"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = FileParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid file params: ${parsed.error.message}`); + } + + const { path, state } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(path, state, context); + } + + // Dry run mode + if (context.dryRun) { + const action = state === "directory" ? "create directory" : "remove"; + context.logger.info(`[dry-run] file ${action}: ${path}`); + return this.noop(`Would ${action} ${path}`); + } + + // Real execution + return this.executeReal(path, state, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + path: string, + state: "directory" | "absent", + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + if (state === "directory") { + if (mock.fileExists(path) && mock.isDirectory(path)) { + return this.noop(`Directory ${path} already exists`); + } + mock.createDirectory(path); + context.logger.info(`[mock] Created directory: ${path}`); + return this.success(`Created directory ${path}`); + } + + // state === "absent" + if (!mock.fileExists(path)) { + return this.noop(`${path} does not exist`); + } + mock.removeFile(path); + context.logger.info(`[mock] Removed: ${path}`); + return this.success(`Removed ${path}`); + } + + /** + * Execute real file operations + */ + private async executeReal( + path: string, + state: "directory" | "absent", + context: ExecutionContext, + ): Promise { + try { + if (state === "directory") { + const file = Bun.file(path); + + // Check if exists + if (await file.exists()) { + // Verify it's a directory using stat + try { + const stat = await Bun.$`test -d ${path}`.quiet(); + if (stat.exitCode === 0) { + return this.noop(`Directory ${path} already exists`); + } + return this.failure(`${path} exists but is not a directory`); + } catch { + // test command failed, path might not be a directory + return this.failure(`${path} exists but is not a directory`); + } + } + + // Create directory with parents + await Bun.$`mkdir -p ${path}`.quiet(); + context.logger.info(`Created directory: ${path}`); + return this.success(`Created directory ${path}`); + } + + // state === "absent" + const file = Bun.file(path); + if (!(await file.exists())) { + return this.noop(`${path} does not exist`); + } + + // Remove file or directory + await Bun.$`rm -rf ${path}`.quiet(); + context.logger.info(`Removed: ${path}`); + return this.success(`Removed ${path}`); + } catch (error) { + return this.failure( + `file operation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if path exists + */ + async exists(params: unknown): Promise { + const parsed = FileParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().fileExists(parsed.data.path); + } + + const file = Bun.file(parsed.data.path); + return file.exists(); + } +} diff --git a/bun/src/plugins/get-url.ts b/bun/src/plugins/get-url.ts new file mode 100644 index 0000000..69c1cdc --- /dev/null +++ b/bun/src/plugins/get-url.ts @@ -0,0 +1,113 @@ +/** + * GetUrl plugin for downloading files from URLs. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { GetUrlParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class GetUrlPlugin extends BasePlugin { + readonly name = "get_url"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = GetUrlParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid get_url params: ${parsed.error.message}`); + } + + const { url, dest } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(url, dest, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] get_url: ${url} -> ${dest}`); + return this.noop(`Would download ${url} to ${dest}`); + } + + // Real execution + return this.executeReal(url, dest, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + url: string, + dest: string, + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + // Idempotent: if file exists, noop + if (mock.fileExists(dest)) { + return this.noop(`File already exists at ${dest}`); + } + + mock.writeFile(dest, `[mock download from ${url}]`); + context.logger.info(`[mock] Downloaded ${url} to ${dest}`); + return this.success(`Downloaded ${url} to ${dest}`); + } + + /** + * Execute real download + */ + private async executeReal( + url: string, + dest: string, + context: ExecutionContext, + ): Promise { + try { + const file = Bun.file(dest); + + // Idempotent: if file exists, noop + if (await file.exists()) { + return this.noop(`File already exists at ${dest}`); + } + + // Ensure parent directory exists + const parentDir = dest.substring(0, dest.lastIndexOf("/")); + if (parentDir) { + await Bun.$`mkdir -p ${parentDir}`.quiet(); + } + + context.logger.info(`Downloading ${url} to ${dest}`); + + // Download using fetch + const response = await fetch(url); + if (!response.ok) { + return this.failure(`Download failed: HTTP ${response.status}`); + } + + const buffer = await response.arrayBuffer(); + await Bun.write(dest, buffer); + + return this.success(`Downloaded ${url} to ${dest}`); + } catch (error) { + return this.failure( + `Download failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if downloaded file exists + */ + async exists(params: unknown): Promise { + const parsed = GetUrlParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().fileExists(parsed.data.dest); + } + + const file = Bun.file(parsed.data.dest); + return file.exists(); + } +} diff --git a/bun/src/plugins/git.ts b/bun/src/plugins/git.ts new file mode 100644 index 0000000..babe0b5 --- /dev/null +++ b/bun/src/plugins/git.ts @@ -0,0 +1,122 @@ +/** + * Git plugin for cloning repositories. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { GitParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class GitPlugin extends BasePlugin { + readonly name = "git"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = GitParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid git params: ${parsed.error.message}`); + } + + const { repo, dest } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(repo, dest, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] git clone ${repo} -> ${dest}`); + return this.noop(`Would clone ${repo} to ${dest}`); + } + + // Real execution + return this.executeReal(repo, dest, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + repo: string, + dest: string, + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + // Idempotent: if already cloned, noop + if (mock.repoExists(dest)) { + return this.noop(`Repository already exists at ${dest}`); + } + + mock.cloneRepo(repo, dest); + context.logger.info(`[mock] Cloned ${repo} to ${dest}`); + return this.success(`Cloned ${repo} to ${dest}`); + } + + /** + * Execute real git clone + */ + private async executeReal( + repo: string, + dest: string, + context: ExecutionContext, + ): Promise { + try { + const file = Bun.file(dest); + + // Idempotent: if destination exists, assume already cloned + if (await file.exists()) { + // Check if it's a git repo + const gitDir = Bun.file(`${dest}/.git`); + if (await gitDir.exists()) { + // Could do git pull here, but for idempotency just noop + return this.noop(`Repository already exists at ${dest}`); + } + return this.failure(`${dest} exists but is not a git repository`); + } + + // Ensure parent directory exists + const parentDir = dest.substring(0, dest.lastIndexOf("/")); + if (parentDir) { + await Bun.$`mkdir -p ${parentDir}`.quiet(); + } + + // Clone repository + context.logger.info(`git clone ${repo} ${dest}`); + const proc = Bun.spawn(["git", "clone", repo, dest], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + return this.failure(`git clone failed: ${stderr.trim()}`); + } + + return this.success(`Cloned ${repo} to ${dest}`); + } catch (error) { + return this.failure( + `git clone failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if repository exists at destination + */ + async exists(params: unknown): Promise { + const parsed = GitParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().repoExists(parsed.data.dest); + } + + const gitDir = Bun.file(`${parsed.data.dest}/.git`); + return gitDir.exists(); + } +} diff --git a/bun/src/plugins/index.ts b/bun/src/plugins/index.ts new file mode 100644 index 0000000..6c7486b --- /dev/null +++ b/bun/src/plugins/index.ts @@ -0,0 +1,20 @@ +/** + * Plugin system exports. + */ + +export { CommandPlugin } from "./command"; +export { CopyPlugin } from "./copy"; +export { DesktopPlugin } from "./desktop"; +// Individual plugins +export { DockerPlugin } from "./docker"; +export { FilePlugin } from "./file"; +export { GetUrlPlugin } from "./get-url"; +export { GitPlugin } from "./git"; +export { LineinfilePlugin } from "./lineinfile"; +// Registry +export { getPluginRegistry, PluginRegistry } from "./registry"; +export { ReplacePlugin } from "./replace"; +export { ReverseproxyPlugin } from "./reverseproxy"; +export { RmPlugin } from "./rm"; +export { ServicePlugin } from "./service"; +export { UnarchivePlugin } from "./unarchive"; diff --git a/bun/src/plugins/lineinfile.ts b/bun/src/plugins/lineinfile.ts new file mode 100644 index 0000000..99e0c31 --- /dev/null +++ b/bun/src/plugins/lineinfile.ts @@ -0,0 +1,117 @@ +/** + * Lineinfile plugin for adding/removing lines in files. + * Ensures a specific line is present or absent in a file. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { LineinfileParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class LineinfilePlugin extends BasePlugin { + readonly name = "lineinfile"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = LineinfileParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid lineinfile params: ${parsed.error.message}`); + } + + const { dest, line, state } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(dest, line, state, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] lineinfile ${state}: "${line}" in ${dest}`); + return this.noop(`Would ${state === "present" ? "add" : "remove"} line in ${dest}`); + } + + // Real execution + return this.executeReal(dest, line, state, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + dest: string, + line: string, + state: "present" | "absent", + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + if (state === "present") { + if (mock.hasLine(dest, line)) { + return this.noop(`Line already present in ${dest}`); + } + mock.addLine(dest, line); + context.logger.info(`[mock] Added line to ${dest}`); + return this.success(`Added line to ${dest}`); + } + + // state === "absent" + if (!mock.hasLine(dest, line)) { + return this.noop(`Line not present in ${dest}`); + } + mock.removeLine(dest, line); + context.logger.info(`[mock] Removed line from ${dest}`); + return this.success(`Removed line from ${dest}`); + } + + /** + * Execute real file operations + */ + private async executeReal( + dest: string, + line: string, + state: "present" | "absent", + context: ExecutionContext, + ): Promise { + try { + const file = Bun.file(dest); + let content = ""; + + // Read existing content if file exists + if (await file.exists()) { + content = await file.text(); + } + + const lines = content.split("\n"); + const lineExists = lines.includes(line); + + if (state === "present") { + if (lineExists) { + return this.noop(`Line already present in ${dest}`); + } + + // Add line at the end + lines.push(line); + const newContent = lines.join("\n"); + await Bun.write(dest, newContent); + context.logger.info(`Added line to ${dest}`); + return this.success(`Added line to ${dest}`); + } + + // state === "absent" + if (!lineExists) { + return this.noop(`Line not present in ${dest}`); + } + + // Remove all occurrences of the line + const newLines = lines.filter((l) => l !== line); + const newContent = newLines.join("\n"); + await Bun.write(dest, newContent); + context.logger.info(`Removed line from ${dest}`); + return this.success(`Removed line from ${dest}`); + } catch (error) { + return this.failure( + `lineinfile failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/bun/src/plugins/registry.ts b/bun/src/plugins/registry.ts new file mode 100644 index 0000000..9653442 --- /dev/null +++ b/bun/src/plugins/registry.ts @@ -0,0 +1,141 @@ +/** + * Plugin registry for discovering and managing task plugins. + * Provides dynamic plugin registration and lookup by alias. + */ + +import type { IPlugin } from "../types/plugin"; + +// ============================================================================= +// PluginRegistry Class +// ============================================================================= + +/** + * Registry for task plugins. Manages plugin registration and lookup. + */ +export class PluginRegistry { + private plugins: Map = new Map(); + + private static instance: PluginRegistry | null = null; + + /** + * Get or create the singleton instance + */ + static getInstance(): PluginRegistry { + if (!PluginRegistry.instance) { + PluginRegistry.instance = new PluginRegistry(); + } + return PluginRegistry.instance; + } + + /** + * Reset singleton (useful for testing) + */ + static resetInstance(): void { + PluginRegistry.instance = null; + } + + /** + * Register a plugin by alias (e.g., "docker", "service") + */ + register(alias: string, plugin: IPlugin): void { + this.plugins.set(alias, plugin); + } + + /** + * Get a plugin by alias + */ + get(alias: string): IPlugin | undefined { + return this.plugins.get(alias); + } + + /** + * Check if a plugin is registered + */ + has(alias: string): boolean { + return this.plugins.has(alias); + } + + /** + * Get all registered plugins + */ + getAll(): Map { + return new Map(this.plugins); + } + + /** + * Get list of registered plugin aliases + */ + getAliases(): string[] { + return Array.from(this.plugins.keys()); + } + + /** + * Clear all registered plugins (useful for testing) + */ + clear(): void { + this.plugins.clear(); + } + + /** + * Load and register all built-in plugins. + * Uses dynamic imports for better tree-shaking. + */ + async loadBuiltinPlugins(): Promise { + // Import all plugin modules dynamically + const [ + { DockerPlugin }, + { ServicePlugin }, + { LineinfilePlugin }, + { ReverseproxyPlugin }, + { FilePlugin }, + { CopyPlugin }, + { GitPlugin }, + { CommandPlugin }, + { RmPlugin }, + { GetUrlPlugin }, + { UnarchivePlugin }, + { ReplacePlugin }, + { DesktopPlugin }, + ] = await Promise.all([ + import("./docker"), + import("./service"), + import("./lineinfile"), + import("./reverseproxy"), + import("./file"), + import("./copy"), + import("./git"), + import("./command"), + import("./rm"), + import("./get-url"), + import("./unarchive"), + import("./replace"), + import("./desktop"), + ]); + + // Register each plugin with its alias + this.register("docker", new DockerPlugin()); + this.register("service", new ServicePlugin()); + this.register("lineinfile", new LineinfilePlugin()); + this.register("reverseproxy", new ReverseproxyPlugin()); + this.register("file", new FilePlugin()); + this.register("copy", new CopyPlugin()); + this.register("git", new GitPlugin()); + this.register("command", new CommandPlugin()); + this.register("rm", new RmPlugin()); + this.register("get_url", new GetUrlPlugin()); + this.register("unarchive", new UnarchivePlugin()); + this.register("replace", new ReplacePlugin()); + this.register("desktop", new DesktopPlugin()); + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +/** + * Get the singleton PluginRegistry instance + */ +export function getPluginRegistry(): PluginRegistry { + return PluginRegistry.getInstance(); +} diff --git a/bun/src/plugins/replace.ts b/bun/src/plugins/replace.ts new file mode 100644 index 0000000..adbbbeb --- /dev/null +++ b/bun/src/plugins/replace.ts @@ -0,0 +1,81 @@ +/** + * Replace plugin for regex-based text replacement in files. + */ + +import { isMockMode } from "../core/mock-state"; +import { ReplaceParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class ReplacePlugin extends BasePlugin { + readonly name = "replace"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = ReplaceParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid replace params: ${parsed.error.message}`); + } + + const { path, regexp, replace } = parsed.data; + + // Mock mode - just log and succeed + if (context.mock || isMockMode()) { + context.logger.info(`[mock] Replace in ${path}: /${regexp}/ -> "${replace}"`); + return this.success(`[mock] Replaced pattern in ${path}`); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] replace: /${regexp}/ -> "${replace}" in ${path}`); + return this.noop(`Would replace pattern in ${path}`); + } + + // Real execution + return this.executeReal(path, regexp, replace, context); + } + + /** + * Execute real replacement + */ + private async executeReal( + path: string, + regexpStr: string, + replaceStr: string, + context: ExecutionContext, + ): Promise { + try { + const file = Bun.file(path); + + if (!(await file.exists())) { + return this.failure(`File does not exist: ${path}`); + } + + const content = await file.text(); + const regex = new RegExp(regexpStr, "g"); + + // Check if there are any matches + if (!regex.test(content)) { + return this.noop(`No matches found for pattern in ${path}`); + } + + // Reset regex after test + regex.lastIndex = 0; + + // Perform replacement + const newContent = content.replace(regex, replaceStr); + + // Check if anything changed (idempotent) + if (content === newContent) { + return this.noop(`File ${path} already has the replacement`); + } + + await Bun.write(path, newContent); + context.logger.info(`Replaced pattern in ${path}`); + return this.success(`Replaced pattern in ${path}`); + } catch (error) { + return this.failure( + `Replace failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/bun/src/plugins/reverseproxy.ts b/bun/src/plugins/reverseproxy.ts new file mode 100644 index 0000000..76c5c68 --- /dev/null +++ b/bun/src/plugins/reverseproxy.ts @@ -0,0 +1,281 @@ +/** + * Reverseproxy plugin for managing nginx reverse proxy configs. + * Creates/removes nginx config files in sites-available/enabled. + * + * Features: + * - Domain transformation: hostname prefix + configured domainBase + * - Wildcard certificate from statePath + * - HTTP→HTTPS redirect + * - Nginx reload after config changes + */ + +import { CertManager } from "../core/cert-manager"; +import { ConfigManager } from "../core/config-manager"; +import { getMockState, isMockMode } from "../core/mock-state"; +import { ReverseproxyParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +const NGINX_SITES_AVAILABLE = "/etc/nginx/sites-available"; +const NGINX_SITES_ENABLED = "/etc/nginx/sites-enabled"; + +export class ReverseproxyPlugin extends BasePlugin { + readonly name = "reverseproxy"; + + /** + * Transform module hostname to use configured domainBase + * e.g., "juice-shop.wtf" with domainBase "abcde.penlabs.net" → "juice-shop.abcde.penlabs.net" + */ + private transformHostname(moduleHostname: string, domainBase: string): string { + // Extract prefix (everything before first dot) + const prefix = moduleHostname.split(".")[0]; + return `${prefix}.${domainBase}`; + } + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = ReverseproxyParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid reverseproxy params: ${parsed.error.message}`); + } + + const { hostname: moduleHostname, proxy_pass } = parsed.data; + + // Load config to get domainBase + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + // Transform hostname using domainBase + const hostname = this.transformHostname(moduleHostname, config.domainBase); + + // Determine action from operation context + const isRemove = context.operation === "remove"; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(hostname, proxy_pass, isRemove, context); + } + + // Dry run mode + if (context.dryRun) { + const action = isRemove ? "remove" : "create"; + context.logger.info(`[dry-run] reverseproxy ${action}: ${hostname}`); + return this.noop(`Would ${action} reverse proxy for ${hostname}`); + } + + // Real execution + return this.executeReal(hostname, proxy_pass, isRemove, context, config.statePath); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + hostname: string, + proxyPass: string | undefined, + isRemove: boolean, + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + if (isRemove) { + if (!mock.reverseProxyExists(hostname)) { + return this.noop(`Reverse proxy for ${hostname} does not exist`); + } + mock.removeReverseProxy(hostname); + context.logger.info(`[mock] Removed reverse proxy: ${hostname}`); + return this.success(`Removed reverse proxy for ${hostname}`); + } + + // Create - check if certs exist + if (!mock.hasCerts()) { + return this.failure( + "Certificates not initialized. Run 'katana cert init' first.", + ); + } + + if (mock.reverseProxyExists(hostname)) { + return this.noop(`Reverse proxy for ${hostname} already exists`); + } + mock.addReverseProxy(hostname, proxyPass); + context.logger.info(`[mock] Created reverse proxy: ${hostname} -> ${proxyPass}`); + return this.success(`Created reverse proxy for ${hostname}`); + } + + /** + * Execute real nginx config operations + */ + private async executeReal( + hostname: string, + proxyPass: string | undefined, + isRemove: boolean, + context: ExecutionContext, + statePath: string, + ): Promise { + const availablePath = `${NGINX_SITES_AVAILABLE}/${hostname}`; + const enabledPath = `${NGINX_SITES_ENABLED}/${hostname}`; + + try { + if (isRemove) { + // Remove symlink and config file + const enabledFile = Bun.file(enabledPath); + const availableFile = Bun.file(availablePath); + + if (!(await availableFile.exists())) { + return this.noop(`Reverse proxy for ${hostname} does not exist`); + } + + // Remove symlink if exists + if (await enabledFile.exists()) { + await Bun.$`rm ${enabledPath}`.quiet(); + } + + // Remove config file + await Bun.$`rm ${availablePath}`.quiet(); + + // Reload nginx + await this.reloadNginx(context); + + context.logger.info(`Removed reverse proxy config: ${hostname}`); + return this.success(`Removed reverse proxy for ${hostname}`); + } + + // Create config + if (!proxyPass) { + return this.failure("proxy_pass is required for creating reverse proxy"); + } + + // Check if certificates exist + const certManager = CertManager.getInstance(); + certManager.setStatePath(statePath); + + if (!(await certManager.hasCerts())) { + return this.failure( + "Certificates not initialized. Run 'katana cert init' first.", + ); + } + + const availableFile = Bun.file(availablePath); + if (await availableFile.exists()) { + return this.noop(`Reverse proxy for ${hostname} already exists`); + } + + // Get certificate paths + const certPaths = certManager.getCertPaths(); + + // Generate nginx config + const config = this.generateNginxConfig(hostname, proxyPass, certPaths.cert, certPaths.key); + + // Write config to sites-available + await Bun.write(availablePath, config); + + // Create symlink in sites-enabled + await Bun.$`ln -sf ${availablePath} ${enabledPath}`.quiet(); + + // Reload nginx + await this.reloadNginx(context); + + context.logger.info(`Created reverse proxy: ${hostname} -> ${proxyPass}`); + return this.success(`Created reverse proxy for ${hostname}`); + } catch (error) { + return this.failure( + `reverseproxy failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Reload nginx configuration + */ + private async reloadNginx(context: ExecutionContext): Promise { + try { + // Check if nginx is installed + try { + await Bun.$`which nginx`.quiet(); + } catch { + context.logger.warn("nginx not found, skipping reload"); + return; + } + + // Test configuration + const testResult = await Bun.$`nginx -t`.quiet(); + if (testResult.exitCode !== 0) { + context.logger.warn(`nginx config test failed: ${testResult.stderr}`); + return; + } + + // Reload nginx - try systemctl first, fall back to nginx -s reload + try { + await Bun.$`systemctl reload nginx`.quiet(); + context.logger.info("Nginx reloaded via systemctl"); + } catch { + // systemctl might not be available, try direct reload + await Bun.$`nginx -s reload`.quiet(); + context.logger.info("Nginx reloaded via nginx -s reload"); + } + } catch (error) { + context.logger.warn( + `Failed to reload nginx: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Generate nginx reverse proxy config with HTTP→HTTPS redirect + */ + private generateNginxConfig( + hostname: string, + proxyPass: string, + certPath: string, + keyPath: string, + ): string { + return `# HTTP -> HTTPS redirect +server { + listen 80; + server_name ${hostname}; + return 301 https://$host$request_uri; +} + +# HTTPS server +server { + listen 443 ssl; + server_name ${hostname}; + + ssl_certificate ${certPath}; + ssl_certificate_key ${keyPath}; + + location / { + proxy_pass ${proxyPass}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +`; + } + + /** + * Check if reverse proxy config exists + */ + async exists(params: unknown): Promise { + const parsed = ReverseproxyParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + // Load config to get domainBase + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + // Transform hostname + const hostname = this.transformHostname(parsed.data.hostname, config.domainBase); + + if (isMockMode()) { + return getMockState().reverseProxyExists(hostname); + } + + const availablePath = `${NGINX_SITES_AVAILABLE}/${hostname}`; + const file = Bun.file(availablePath); + return file.exists(); + } +} diff --git a/bun/src/plugins/rm.ts b/bun/src/plugins/rm.ts new file mode 100644 index 0000000..e7e5e2f --- /dev/null +++ b/bun/src/plugins/rm.ts @@ -0,0 +1,84 @@ +/** + * Rm plugin for removing files and directories. + * Supports single path or array of paths. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { RmParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class RmPlugin extends BasePlugin { + readonly name = "rm"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = RmParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid rm params: ${parsed.error.message}`); + } + + const paths = Array.isArray(parsed.data.path) ? parsed.data.path : [parsed.data.path]; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(paths, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] rm: ${paths.join(", ")}`); + return this.noop(`Would remove: ${paths.join(", ")}`); + } + + // Real execution + return this.executeReal(paths, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock(paths: string[], context: ExecutionContext): Promise { + const mock = getMockState(); + let anyRemoved = false; + + for (const path of paths) { + if (mock.fileExists(path)) { + mock.removeFile(path); + anyRemoved = true; + context.logger.info(`[mock] Removed: ${path}`); + } + } + + if (!anyRemoved) { + return this.noop("No files to remove"); + } + + return this.success(`Removed ${paths.length} path(s)`); + } + + /** + * Execute real rm operations + */ + private async executeReal(paths: string[], context: ExecutionContext): Promise { + try { + let anyRemoved = false; + + for (const path of paths) { + const file = Bun.file(path); + if (await file.exists()) { + await Bun.$`rm -rf ${path}`.quiet(); + anyRemoved = true; + context.logger.info(`Removed: ${path}`); + } + } + + if (!anyRemoved) { + return this.noop("No files to remove"); + } + + return this.success(`Removed ${paths.length} path(s)`); + } catch (error) { + return this.failure(`rm failed: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/bun/src/plugins/service.ts b/bun/src/plugins/service.ts new file mode 100644 index 0000000..7bd64b8 --- /dev/null +++ b/bun/src/plugins/service.ts @@ -0,0 +1,204 @@ +/** + * Service plugin for managing systemd services. + * Supports start, stop, and restart operations. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { ServiceParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class ServicePlugin extends BasePlugin { + readonly name = "service"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = ServiceParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid service params: ${parsed.error.message}`); + } + + const { name, state } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(name, state, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] systemctl: ${state} ${name}`); + return this.noop(`Would ${state} service ${name}`); + } + + // Real execution + return this.executeReal(name, state, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + name: string, + state: "running" | "stopped" | "restarted", + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + switch (state) { + case "running": { + if (mock.serviceRunning(name)) { + return this.noop(`Service ${name} already running`); + } + mock.startService(name); + context.logger.info(`[mock] Started service: ${name}`); + return this.success(`Started service ${name}`); + } + + case "stopped": { + if (!mock.serviceRunning(name)) { + return this.noop(`Service ${name} already stopped`); + } + mock.stopService(name); + context.logger.info(`[mock] Stopped service: ${name}`); + return this.success(`Stopped service ${name}`); + } + + case "restarted": { + mock.restartService(name); + context.logger.info(`[mock] Restarted service: ${name}`); + return this.success(`Restarted service ${name}`); + } + + default: + return this.failure(`Unknown service state: ${state}`); + } + } + + /** + * Execute real systemctl commands + */ + private async executeReal( + name: string, + state: "running" | "stopped" | "restarted", + context: ExecutionContext, + ): Promise { + switch (state) { + case "running": { + // Check if already running (idempotent) + if (await this.serviceRunning(name)) { + return this.noop(`Service ${name} already running`); + } + return this.runSystemctl("start", name, context); + } + + case "stopped": { + // Check if already stopped (idempotent) + if (!(await this.serviceRunning(name))) { + return this.noop(`Service ${name} already stopped`); + } + return this.runSystemctl("stop", name, context); + } + + case "restarted": { + // Restart always changes state + return this.runSystemctl("restart", name, context); + } + + default: + return this.failure(`Unknown service state: ${state}`); + } + } + + /** + * Run a systemctl command and return result + */ + private async runSystemctl( + action: string, + name: string, + context: ExecutionContext, + ): Promise { + try { + context.logger.info(`systemctl ${action} ${name}`); + + const proc = Bun.spawn(["systemctl", action, name], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + const stderr = await new Response(proc.stderr).text(); + + if (exitCode !== 0) { + return this.failure(`systemctl ${action} ${name} failed: ${stderr.trim()}`); + } + + return this.success(`systemctl ${action} ${name}`); + } catch (error) { + return this.failure( + `systemctl command failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if a service exists + */ + async exists(params: unknown): Promise { + const parsed = ServiceParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().serviceExists(parsed.data.name); + } + + return this.serviceExists(parsed.data.name); + } + + /** + * Check if a service is running + */ + async started(params: unknown): Promise { + const parsed = ServiceParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().serviceRunning(parsed.data.name); + } + + return this.serviceRunning(parsed.data.name); + } + + /** + * Check if a service exists (real systemctl) + */ + private async serviceExists(name: string): Promise { + try { + const proc = Bun.spawn(["systemctl", "list-unit-files", `${name}.service`, "--no-legend"], { + stdout: "pipe", + }); + const output = await new Response(proc.stdout).text(); + return output.trim().length > 0; + } catch { + return false; + } + } + + /** + * Check if a service is running (real systemctl) + */ + private async serviceRunning(name: string): Promise { + try { + const proc = Bun.spawn(["systemctl", "is-active", name], { + stdout: "pipe", + }); + const output = await new Response(proc.stdout).text(); + return output.trim() === "active"; + } catch { + return false; + } + } +} diff --git a/bun/src/plugins/unarchive.ts b/bun/src/plugins/unarchive.ts new file mode 100644 index 0000000..6a7340b --- /dev/null +++ b/bun/src/plugins/unarchive.ts @@ -0,0 +1,134 @@ +/** + * Unarchive plugin for downloading and extracting tar.gz files. + */ + +import { getMockState, isMockMode } from "../core/mock-state"; +import { UnarchiveParamsSchema } from "../types/module"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../types/plugin"; + +export class UnarchivePlugin extends BasePlugin { + readonly name = "unarchive"; + + async execute(params: unknown, context: ExecutionContext): Promise { + // Validate params + const parsed = UnarchiveParamsSchema.safeParse(params); + if (!parsed.success) { + return this.failure(`Invalid unarchive params: ${parsed.error.message}`); + } + + const { url, dest, cleanup } = parsed.data; + + // Mock mode + if (context.mock || isMockMode()) { + return this.executeMock(url, dest, context); + } + + // Dry run mode + if (context.dryRun) { + context.logger.info(`[dry-run] unarchive: ${url} -> ${dest}`); + return this.noop(`Would extract ${url} to ${dest}`); + } + + // Real execution + return this.executeReal(url, dest, cleanup ?? false, context); + } + + /** + * Execute in mock mode using MockState + */ + private async executeMock( + url: string, + dest: string, + context: ExecutionContext, + ): Promise { + const mock = getMockState(); + + // Idempotent: if destination exists, noop + if (mock.fileExists(dest)) { + return this.noop(`Destination already exists at ${dest}`); + } + + mock.createDirectory(dest); + context.logger.info(`[mock] Extracted ${url} to ${dest}`); + return this.success(`Extracted ${url} to ${dest}`); + } + + /** + * Execute real download and extract + */ + private async executeReal( + url: string, + dest: string, + cleanup: boolean, + context: ExecutionContext, + ): Promise { + try { + const destFile = Bun.file(dest); + + // Idempotent: if destination exists, noop + if (await destFile.exists()) { + return this.noop(`Destination already exists at ${dest}`); + } + + // Ensure parent directory exists + await Bun.$`mkdir -p ${dest}`.quiet(); + + // Create temp file for download + const tempPath = `/tmp/unarchive-${Date.now()}.tar.gz`; + + context.logger.info(`Downloading ${url}`); + + // Download the archive + const response = await fetch(url); + if (!response.ok) { + return this.failure(`Download failed: HTTP ${response.status}`); + } + + const buffer = await response.arrayBuffer(); + await Bun.write(tempPath, buffer); + + // Extract the archive + context.logger.info(`Extracting to ${dest}`); + + const proc = Bun.spawn(["tar", "-xzf", tempPath, "-C", dest, "--strip-components=1"], { + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + await Bun.$`rm -f ${tempPath}`.quiet(); + return this.failure(`Extraction failed: ${stderr.trim()}`); + } + + // Cleanup temp file + if (cleanup) { + await Bun.$`rm -f ${tempPath}`.quiet(); + } + + return this.success(`Extracted ${url} to ${dest}`); + } catch (error) { + return this.failure( + `Unarchive failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Check if extracted directory exists + */ + async exists(params: unknown): Promise { + const parsed = UnarchiveParamsSchema.safeParse(params); + if (!parsed.success) { + return false; + } + + if (isMockMode()) { + return getMockState().fileExists(parsed.data.dest); + } + + const file = Bun.file(parsed.data.dest); + return file.exists(); + } +} diff --git a/bun/src/server/index.ts b/bun/src/server/index.ts new file mode 100644 index 0000000..d03f8ee --- /dev/null +++ b/bun/src/server/index.ts @@ -0,0 +1,237 @@ +/** + * Main HTTP server using Bun.serve() with native routing + */ + +import type { Server } from "bun"; + +// Server type with default WebSocket data +type HttpServer = Server; + +import type { Config } from "../types/config"; +import { + createLogger, + errorHandler, + getLogger, + handleCors, + logRequest, + logResponse, + withCors, +} from "./middleware"; +import { disableLock, enableLock, getConfig, getLockStatus } from "./routes/config"; +import { healthCheck } from "./routes/health"; +import { + getModule, + getModuleStatus, + installModule, + listModules, + removeModule, + startModule, + stopModule, +} from "./routes/modules"; +import { getOperation, streamOperation } from "./routes/operations"; +import { errorResponse, jsonResponse } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface TlsConfig { + cert: string; + key: string; +} + +export interface ServerOptions { + config: Config; + tls?: TlsConfig; +} + +// ============================================================================= +// Server Creation +// ============================================================================= + +/** + * Create and start the HTTP server + */ +export function createServer(options: ServerOptions): HttpServer { + const { config, tls } = options; + + // Initialize logger + createLogger(config); + const logger = getLogger(); + + const protocol = tls ? "https" : "http"; + logger.info({ port: config.server.port, host: config.server.host, tls: !!tls }, "Starting server"); + + // Build server config + const serverConfig: Parameters[0] = { + port: config.server.port, + hostname: config.server.host, + + // Main request handler + async fetch(req: Request): Promise { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + const start = performance.now(); + + // Log incoming request + logRequest(req, path); + + // Handle CORS preflight + if (method === "OPTIONS" && config.server.cors) { + const corsResponse = handleCors(config); + if (corsResponse) { + return corsResponse; + } + } + + try { + // Route the request + const response = await routeRequest(req, path, method, config); + + // Add CORS headers if enabled + const finalResponse = withCors(response, config); + + // Log response + const duration = performance.now() - start; + logResponse(req, path, finalResponse.status, duration); + + return finalResponse; + } catch (error) { + // Log and handle errors + const duration = performance.now() - start; + logger.error({ err: error, path, method }, "Request error"); + logResponse(req, path, 500, duration); + + return withCors(errorHandler(error as Error), config); + } + }, + + // Global error handler + error(error: Error): Response { + return errorHandler(error); + }, + }; + + // Add TLS configuration if provided + if (tls) { + serverConfig.tls = { + cert: Bun.file(tls.cert), + key: Bun.file(tls.key), + }; + } + + return Bun.serve(serverConfig); +} + +/** + * Route a request to the appropriate handler + */ +async function routeRequest( + req: Request, + path: string, + method: string, + _config: Config, +): Promise { + // Health check + if (path === "/health" && method === "GET") { + return healthCheck(); + } + + // Config endpoint + if (path === "/api/config" && method === "GET") { + return getConfig(); + } + + // Lock endpoints + if (path === "/api/lock") { + if (method === "GET") return getLockStatus(); + if (method === "POST") return enableLock(req); + if (method === "DELETE") return disableLock(); + } + + // Module list route + if (path === "/api/modules" && method === "GET") { + return listModules(req); + } + + // Module operation routes: /api/modules/:name/(install|remove|start|stop) + const moduleOpMatch = path.match(/^\/api\/modules\/([^/]+)\/(install|remove|start|stop)$/); + if (moduleOpMatch?.[1] && moduleOpMatch[2] && method === "POST") { + const moduleName = decodeURIComponent(moduleOpMatch[1]); + const operation = moduleOpMatch[2]; + + switch (operation) { + case "install": + return installModule(moduleName); + case "remove": + return removeModule(moduleName); + case "start": + return startModule(moduleName); + case "stop": + return stopModule(moduleName); + } + } + + // Module status route: /api/modules/:name/status + const moduleStatusMatch = path.match(/^\/api\/modules\/([^/]+)\/status$/); + if (moduleStatusMatch?.[1] && method === "GET") { + const moduleName = decodeURIComponent(moduleStatusMatch[1]); + return getModuleStatus(moduleName); + } + + // Module detail route: /api/modules/:name (but not /api/modules/:name/*, handled above) + const moduleDetailMatch = path.match(/^\/api\/modules\/([^/]+)$/); + if (moduleDetailMatch?.[1] && method === "GET") { + const moduleName = decodeURIComponent(moduleDetailMatch[1]); + return getModule(moduleName); + } + + // Operation routes + const operationStreamMatch = path.match(/^\/api\/operations\/([^/]+)\/stream$/); + if (operationStreamMatch?.[1] && method === "GET") { + const operationId = decodeURIComponent(operationStreamMatch[1]); + return streamOperation(operationId); + } + + const operationMatch = path.match(/^\/api\/operations\/([^/]+)$/); + if (operationMatch?.[1] && method === "GET") { + const operationId = decodeURIComponent(operationMatch[1]); + return getOperation(operationId); + } + + // 404 for all other routes + return jsonResponse(errorResponse("NOT_FOUND", `Endpoint not found: ${method} ${path}`), 404); +} + +/** + * Print server startup info + */ +export function printServerInfo(config: Config, tls = false): void { + const protocol = tls ? "https" : "http"; + const baseUrl = `${protocol}://${config.server.host}:${config.server.port}`; + + console.log(""); + console.log(`Katana API server listening on ${baseUrl}`); + if (tls) { + console.log("TLS enabled"); + } + console.log(""); + console.log("Endpoints:"); + console.log(` GET ${baseUrl}/health`); + console.log(` GET ${baseUrl}/api/modules`); + console.log(` GET ${baseUrl}/api/modules/:name`); + console.log(` GET ${baseUrl}/api/modules/:name/status`); + console.log(` POST ${baseUrl}/api/modules/:name/install`); + console.log(` POST ${baseUrl}/api/modules/:name/remove`); + console.log(` POST ${baseUrl}/api/modules/:name/start`); + console.log(` POST ${baseUrl}/api/modules/:name/stop`); + console.log(` GET ${baseUrl}/api/operations/:id`); + console.log(` GET ${baseUrl}/api/operations/:id/stream`); + console.log(` GET ${baseUrl}/api/config`); + console.log(` GET ${baseUrl}/api/lock`); + console.log(` POST ${baseUrl}/api/lock`); + console.log(` DELETE ${baseUrl}/api/lock`); + console.log(""); + console.log("Press Ctrl+C to stop"); +} diff --git a/bun/src/server/middleware.ts b/bun/src/server/middleware.ts new file mode 100644 index 0000000..73b8822 --- /dev/null +++ b/bun/src/server/middleware.ts @@ -0,0 +1,173 @@ +/** + * Server middleware helpers for CORS, logging, and error handling + */ + +import pino from "pino"; +import type { Config } from "../types/config"; +import { errorResponse, jsonResponse } from "./types"; + +// ============================================================================= +// Logger +// ============================================================================= + +let loggerInstance: pino.Logger | null = null; + +/** + * Create or get the logger instance + */ +export function createLogger(config: Config): pino.Logger { + if (loggerInstance) { + return loggerInstance; + } + + const options: pino.LoggerOptions = { + level: config.log.level, + }; + + // Use pino-pretty for pretty format, otherwise default JSON + if (config.log.format === "pretty") { + options.transport = { + target: "pino-pretty", + options: { + colorize: true, + }, + }; + } + + loggerInstance = pino(options); + return loggerInstance; +} + +/** + * Get the current logger instance (creates default if not initialized) + */ +export function getLogger(): pino.Logger { + if (!loggerInstance) { + loggerInstance = pino({ level: "info" }); + } + return loggerInstance; +} + +/** + * Reset the logger instance (for testing) + */ +export function resetLogger(): void { + loggerInstance = null; +} + +// ============================================================================= +// CORS +// ============================================================================= + +/** + * CORS headers for development mode + */ +export function corsHeaders(config: Config): Record { + if (!config.server.cors) { + return {}; + } + + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +/** + * Handle CORS preflight requests + */ +export function handleCors(config: Config): Response | null { + if (!config.server.cors) { + return null; + } + + return new Response(null, { + status: 204, + headers: corsHeaders(config), + }); +} + +// ============================================================================= +// Error Handling +// ============================================================================= + +/** + * Global error handler for unhandled errors + */ +export function errorHandler(error: Error): Response { + const logger = getLogger(); + logger.error({ err: error }, "Unhandled server error"); + + const isDev = process.env.NODE_ENV === "development"; + + return jsonResponse( + errorResponse( + "INTERNAL_ERROR", + "An internal server error occurred", + isDev ? error.message : undefined, + ), + 500, + ); +} + +// ============================================================================= +// Request Logging +// ============================================================================= + +/** + * Log an incoming request + */ +export function logRequest(req: Request, path: string): void { + const logger = getLogger(); + logger.info( + { + method: req.method, + path, + userAgent: req.headers.get("user-agent"), + }, + "Request received", + ); +} + +/** + * Log a response + */ +export function logResponse(req: Request, path: string, status: number, duration: number): void { + const logger = getLogger(); + const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info"; + + logger[level]( + { + method: req.method, + path, + status, + duration: `${duration.toFixed(2)}ms`, + }, + "Request completed", + ); +} + +// ============================================================================= +// Response Helpers +// ============================================================================= + +/** + * Add CORS headers to a response if enabled + */ +export function withCors(response: Response, config: Config): Response { + if (!config.server.cors) { + return response; + } + + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(corsHeaders(config))) { + headers.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/bun/src/server/operations.ts b/bun/src/server/operations.ts new file mode 100644 index 0000000..a120aed --- /dev/null +++ b/bun/src/server/operations.ts @@ -0,0 +1,493 @@ +/** + * OperationManager - tracks async module operations and bridges to SSE + */ + +import { DependencyResolver } from "../core/dependencies"; +import { allSucceeded, TaskExecutor, type TaskResult } from "../core/executor"; +import { loadAllModules, loadModule } from "../core/module-loader"; +import { StateManager } from "../core/state-manager"; +import { StatusChecker } from "../core/status"; +import { getPluginRegistry } from "../plugins/registry"; +import { formatSSEMessage, type SSEEvent } from "../types/events"; +import type { Task } from "../types/module"; +import type { Operation } from "../types/plugin"; +import { getLogger } from "./middleware"; +import type { OperationStatus } from "./types"; + +// ============================================================================= +// Types +// ============================================================================= + +export interface TrackedOperation { + id: string; + module: string; + operation: Operation; + status: OperationStatus; + startedAt: Date; + completedAt?: Date; + results?: TaskResult[]; + error?: string; + subscribers: Set>; +} + +export interface OperationManagerOptions { + maxConcurrent?: number; + operationTimeout?: number; // ms, default 5 minutes +} + +// ============================================================================= +// OperationManager +// ============================================================================= + +const DEFAULT_MAX_CONCURRENT = 3; +const DEFAULT_OPERATION_TIMEOUT = 5 * 60 * 1000; // 5 minutes +const OPERATION_CLEANUP_AGE = 60 * 60 * 1000; // 1 hour + +let instance: OperationManager | null = null; + +export class OperationManager { + private operations = new Map(); + private moduleOperations = new Map(); // module -> operationId + private runningCount = 0; + private maxConcurrent: number; + private operationTimeout: number; + private cleanupTimer?: Timer; + + private constructor(options: OperationManagerOptions = {}) { + this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + this.operationTimeout = options.operationTimeout ?? DEFAULT_OPERATION_TIMEOUT; + + // Start cleanup timer + this.cleanupTimer = setInterval(() => this.cleanup(), OPERATION_CLEANUP_AGE / 2); + } + + /** + * Get the singleton instance + */ + static getInstance(options?: OperationManagerOptions): OperationManager { + if (!instance) { + instance = new OperationManager(options); + } + return instance; + } + + /** + * Reset the singleton (for testing) + */ + static resetInstance(): void { + if (instance) { + if (instance.cleanupTimer) { + clearInterval(instance.cleanupTimer); + } + instance = null; + } + } + + /** + * Create and start a new operation + */ + async createOperation(module: string, operation: Operation): Promise { + const logger = getLogger(); + + // Generate operation ID + const id = crypto.randomUUID(); + + // Create tracked operation + const tracked: TrackedOperation = { + id, + module, + operation, + status: "queued", + startedAt: new Date(), + subscribers: new Set(), + }; + + this.operations.set(id, tracked); + this.moduleOperations.set(module.toLowerCase(), id); + + logger.info({ operationId: id, module, operation }, "Operation created"); + + // Start execution asynchronously + this.executeOperation(tracked); + + return tracked; + } + + /** + * Get operation by ID + */ + getOperation(id: string): TrackedOperation | undefined { + return this.operations.get(id); + } + + /** + * Check if module has an operation in progress + */ + hasOperationInProgress(module: string): boolean { + const operationId = this.moduleOperations.get(module.toLowerCase()); + if (!operationId) return false; + + const operation = this.operations.get(operationId); + if (!operation) return false; + + return operation.status === "queued" || operation.status === "running"; + } + + /** + * Subscribe to operation events + */ + subscribe(operationId: string, controller: ReadableStreamDefaultController): boolean { + const operation = this.operations.get(operationId); + if (!operation) return false; + + operation.subscribers.add(controller); + return true; + } + + /** + * Unsubscribe from operation events + */ + unsubscribe(operationId: string, controller: ReadableStreamDefaultController): void { + const operation = this.operations.get(operationId); + if (operation) { + operation.subscribers.delete(controller); + } + } + + /** + * Broadcast SSE event to all subscribers + */ + broadcast(operationId: string, event: SSEEvent): void { + const operation = this.operations.get(operationId); + if (!operation) return; + + const message = formatSSEMessage(event); + const encoder = new TextEncoder(); + const data = encoder.encode(message); + + for (const controller of operation.subscribers) { + try { + controller.enqueue(data); + } catch { + // Controller closed, will be cleaned up + operation.subscribers.delete(controller); + } + } + } + + /** + * Close all subscriber connections for an operation + */ + closeSubscribers(operationId: string): void { + const operation = this.operations.get(operationId); + if (!operation) return; + + for (const controller of operation.subscribers) { + try { + controller.close(); + } catch { + // Already closed + } + } + operation.subscribers.clear(); + } + + /** + * Execute an operation + */ + private async executeOperation(tracked: TrackedOperation): Promise { + const logger = getLogger(); + + // Wait for concurrency slot + while (this.runningCount >= this.maxConcurrent) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + this.runningCount++; + tracked.status = "running"; + + // Set timeout + const timeoutHandle = setTimeout(() => { + if (tracked.status === "running") { + this.failOperation(tracked, "Operation timed out"); + } + }, this.operationTimeout); + + try { + logger.info({ operationId: tracked.id, module: tracked.module }, "Operation starting"); + + if (tracked.operation === "install") { + await this.executeInstall(tracked); + } else { + await this.executeSingleOperation(tracked); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.failOperation(tracked, message); + } finally { + clearTimeout(timeoutHandle); + this.runningCount--; + } + } + + /** + * Execute install operation with dependency resolution + */ + private async executeInstall(tracked: TrackedOperation): Promise { + // Load all modules for dependency resolution + const allModulesResult = await loadAllModules(); + const resolver = new DependencyResolver(allModulesResult.modules); + + // Resolve installation order + const resolution = resolver.getInstallOrder(tracked.module); + if (!resolution.success) { + const errorMsg = resolution.errors.map((e) => e.message).join("; "); + this.failOperation(tracked, `Dependency error: ${errorMsg}`); + return; + } + + // Install dependencies first + const statusChecker = new StatusChecker(); + for (const depName of resolution.order) { + if (depName.toLowerCase() === tracked.module.toLowerCase()) continue; + + // Check if already installed + const depModule = allModulesResult.modules.find( + (m) => m.name.toLowerCase() === depName.toLowerCase(), + ); + if (depModule) { + const status = await statusChecker.checkStatus(depModule); + if (status.installed) { + this.broadcast(tracked.id, { + type: "log", + level: "info", + message: `Dependency ${depName} already installed, skipping`, + timestamp: new Date().toISOString(), + }); + continue; + } + } + + // Install dependency + this.broadcast(tracked.id, { + type: "log", + level: "info", + message: `Installing dependency: ${depName}`, + timestamp: new Date().toISOString(), + }); + + const success = await this.installModule(depName, tracked); + if (!success) { + this.failOperation(tracked, `Failed to install dependency: ${depName}`); + return; + } + } + + // Install the main module + const success = await this.installModule(tracked.module, tracked); + if (success) { + this.completeOperation(tracked); + } + } + + /** + * Install a single module and broadcast progress + */ + private async installModule(moduleName: string, tracked: TrackedOperation): Promise { + return this.executeModuleTasks(moduleName, "install", tracked); + } + + /** + * Execute a single operation (remove/start/stop) + */ + private async executeSingleOperation(tracked: TrackedOperation): Promise { + const success = await this.executeModuleTasks(tracked.module, tracked.operation, tracked); + if (success) { + this.completeOperation(tracked); + } + } + + /** + * Execute tasks for a module and broadcast progress + */ + private async executeModuleTasks( + moduleName: string, + operation: Operation, + tracked: TrackedOperation, + ): Promise { + const result = await loadModule(moduleName); + if (!result.success || !result.module) { + this.broadcast(tracked.id, { + type: "error", + message: `Module not found: ${moduleName}`, + }); + return false; + } + + const mod = result.module; + const tasks = mod[operation] as Task[] | undefined; + + if (!tasks || tasks.length === 0) { + this.broadcast(tracked.id, { + type: "log", + level: "info", + message: `Module ${moduleName} has no ${operation} tasks`, + timestamp: new Date().toISOString(), + }); + return true; + } + + // Load plugins + const registry = getPluginRegistry(); + await registry.loadBuiltinPlugins(); + + // Create executor + const executor = new TaskExecutor({ dryRun: false }); + + // Bridge TaskExecutor events to SSE + executor.on("task:start", (task, index, total) => { + const taskName = task.name ?? `Task ${index + 1}`; + this.broadcast(tracked.id, { + type: "progress", + task: taskName, + current: index + 1, + total, + }); + }); + + executor.on("task:complete", (task, taskResult, _index, _total) => { + const taskName = task.name ?? "Task"; + if (!taskResult.success) { + this.broadcast(tracked.id, { + type: "log", + level: "error", + message: taskResult.message ?? `${taskName} failed`, + timestamp: new Date().toISOString(), + }); + } else if (taskResult.changed) { + this.broadcast(tracked.id, { + type: "log", + level: "info", + message: `${taskName}: changed`, + timestamp: new Date().toISOString(), + }); + } + }); + + executor.on("task:error", (task, error, _index, _total) => { + this.broadcast(tracked.id, { + type: "error", + message: error.message, + task: task.name ?? "unknown", + }); + }); + + executor.on("log", (level, message) => { + this.broadcast(tracked.id, { + type: "log", + level: level as "debug" | "info" | "warn" | "error", + message, + timestamp: new Date().toISOString(), + }); + }); + + // Execute tasks + const results = await executor.execute(tasks, operation); + const success = allSucceeded(results); + + // Update state on success + if (success) { + const stateManager = StateManager.getInstance(); + if (operation === "install") { + await stateManager.installModule(moduleName); + } else if (operation === "remove") { + await stateManager.removeModule(moduleName); + } + } + + // Store results + if (moduleName.toLowerCase() === tracked.module.toLowerCase()) { + tracked.results = results; + } + + if (!success) { + const failures = results.filter((r) => !r.result.success); + const errorMsg = failures.map((f) => f.result.message).join("; "); + this.broadcast(tracked.id, { + type: "error", + message: `${operation} failed: ${errorMsg}`, + }); + this.failOperation(tracked, errorMsg); + } + + return success; + } + + /** + * Mark operation as completed + */ + private completeOperation(tracked: TrackedOperation): void { + const logger = getLogger(); + tracked.status = "completed"; + tracked.completedAt = new Date(); + + const duration = tracked.completedAt.getTime() - tracked.startedAt.getTime(); + + logger.info( + { operationId: tracked.id, module: tracked.module, duration }, + "Operation completed", + ); + + this.broadcast(tracked.id, { + type: "complete", + module: tracked.module, + operation: tracked.operation, + success: true, + duration, + }); + + // Close subscribers after a short delay to ensure they receive the complete event + setTimeout(() => this.closeSubscribers(tracked.id), 100); + } + + /** + * Mark operation as failed + */ + private failOperation(tracked: TrackedOperation, error: string): void { + const logger = getLogger(); + tracked.status = "failed"; + tracked.completedAt = new Date(); + tracked.error = error; + + const duration = tracked.completedAt.getTime() - tracked.startedAt.getTime(); + + logger.error({ operationId: tracked.id, module: tracked.module, error }, "Operation failed"); + + this.broadcast(tracked.id, { + type: "complete", + module: tracked.module, + operation: tracked.operation, + success: false, + duration, + }); + + // Close subscribers after a short delay + setTimeout(() => this.closeSubscribers(tracked.id), 100); + } + + /** + * Clean up old completed operations + */ + cleanup(maxAge = OPERATION_CLEANUP_AGE): void { + const now = Date.now(); + + for (const [id, operation] of this.operations) { + if (operation.status === "completed" || operation.status === "failed") { + if (operation.completedAt && now - operation.completedAt.getTime() > maxAge) { + this.operations.delete(id); + this.moduleOperations.delete(operation.module.toLowerCase()); + } + } + } + } +} diff --git a/bun/src/server/routes/config.ts b/bun/src/server/routes/config.ts new file mode 100644 index 0000000..2195601 --- /dev/null +++ b/bun/src/server/routes/config.ts @@ -0,0 +1,123 @@ +/** + * Config and Lock REST API endpoints + */ + +import { ConfigManager } from "../../core/config-manager"; +import { StateManager } from "../../core/state-manager"; +import { + type ConfigResponse, + errorResponse, + jsonResponse, + type LockStatusResponse, + successResponse, +} from "../types"; + +// ============================================================================= +// Config +// ============================================================================= + +/** + * GET /api/config - Get current configuration (sanitized) + */ +export async function getConfig(): Promise { + const configManager = ConfigManager.getInstance(); + const config = await configManager.loadConfig(); + + const response: ConfigResponse = { + domainBase: config.domainBase, + serverPort: config.server.port, + serverHost: config.server.host, + }; + + return jsonResponse(successResponse(response)); +} + +// ============================================================================= +// Lock Status +// ============================================================================= + +/** + * GET /api/lock - Get lock status + */ +export async function getLockStatus(): Promise { + const stateManager = StateManager.getInstance(); + const lockState = await stateManager.getLockState(); + + const response: LockStatusResponse = { + locked: lockState.locked, + modules: lockState.modules, + lockedAt: lockState.lockedAt, + lockedBy: lockState.lockedBy, + message: lockState.message, + }; + + return jsonResponse(successResponse(response)); +} + +/** + * POST /api/lock - Enable lock mode + */ +export async function enableLock(req: Request): Promise { + const stateManager = StateManager.getInstance(); + + // Check if already locked + if (await stateManager.isLocked()) { + const state = await stateManager.getLockState(); + return jsonResponse( + errorResponse( + "VALIDATION_ERROR", + `System is already locked${state.lockedBy ? ` by ${state.lockedBy}` : ""}`, + ), + 400, + ); + } + + // Parse request body for message + let message: string | undefined; + try { + const body = (await req.json()) as Record; + if (typeof body.message === "string") { + message = body.message; + } + } catch { + // No body or invalid JSON - that's okay + } + + await stateManager.enableLock({ + message, + lockedBy: process.env.USER ?? "api", + }); + + const lockState = await stateManager.getLockState(); + + const response: LockStatusResponse = { + locked: true, + modules: lockState.modules, + lockedAt: lockState.lockedAt, + lockedBy: lockState.lockedBy, + message: lockState.message, + }; + + return jsonResponse(successResponse(response)); +} + +/** + * DELETE /api/lock - Disable lock mode + */ +export async function disableLock(): Promise { + const stateManager = StateManager.getInstance(); + + // Check if not locked + if (!(await stateManager.isLocked())) { + return jsonResponse(errorResponse("VALIDATION_ERROR", "System is not locked"), 400); + } + + await stateManager.disableLock(); + + const response: LockStatusResponse = { + locked: false, + modules: [], + }; + + return jsonResponse(successResponse(response)); +} diff --git a/bun/src/server/routes/health.ts b/bun/src/server/routes/health.ts new file mode 100644 index 0000000..0a5071a --- /dev/null +++ b/bun/src/server/routes/health.ts @@ -0,0 +1,21 @@ +/** + * Health check endpoint + */ + +import type { HealthResponse } from "../types"; +import { jsonResponse, successResponse } from "../types"; + +// Version from package.json +const VERSION = "0.1.0"; + +/** + * GET /health - Health check endpoint + */ +export function healthCheck(): Response { + const data: HealthResponse = { + status: "ok", + version: VERSION, + }; + + return jsonResponse(successResponse(data)); +} diff --git a/bun/src/server/routes/modules.ts b/bun/src/server/routes/modules.ts new file mode 100644 index 0000000..ea50792 --- /dev/null +++ b/bun/src/server/routes/modules.ts @@ -0,0 +1,281 @@ +/** + * Module REST API endpoints + */ + +import { loadAllModules, loadModule } from "../../core/module-loader"; +import { StateManager } from "../../core/state-manager"; +import { StatusChecker } from "../../core/status"; +import type { ModuleCategory } from "../../types/module"; +import type { Operation } from "../../types/plugin"; +import { ModuleStatus } from "../../types/status"; +import { OperationManager } from "../operations"; +import { + errorResponse, + jsonResponse, + type ModuleDetail, + type ModuleListItem, + type ModuleListResponse, + type OperationResponse, + successResponse, +} from "../types"; + +// ============================================================================= +// Lock Mode Helpers +// ============================================================================= + +/** + * Check if a module is accessible when the system is locked. + * Returns true if not locked, or if the module is in the locked list. + */ +function isModuleAccessibleWhenLocked( + name: string, + lockState: { locked: boolean; modules: string[] }, +): boolean { + if (!lockState.locked) return true; + const lockedNames = new Set(lockState.modules.map((m) => m.toLowerCase())); + return lockedNames.has(name.toLowerCase()); +} + +// ============================================================================= +// List Modules +// ============================================================================= + +/** + * GET /api/modules - List all modules with optional category filter + * Query params: + * - category: Filter by category (targets, tools, base, management) + */ +export async function listModules(req: Request): Promise { + const url = new URL(req.url); + const categoryParam = url.searchParams.get("category"); + + // Validate category if provided + const validCategories = ["targets", "tools", "base", "management"]; + if (categoryParam && !validCategories.includes(categoryParam)) { + return jsonResponse( + errorResponse( + "VALIDATION_ERROR", + `Invalid category: ${categoryParam}. Valid: ${validCategories.join(", ")}`, + ), + 400, + ); + } + + // Check lock state + const stateManager = StateManager.getInstance(); + const lockState = await stateManager.getLockState(); + + // Load modules with optional category filter + const loaderOptions = categoryParam ? { category: categoryParam as ModuleCategory } : {}; + const result = await loadAllModules(loaderOptions); + + if (!result.success && result.modules.length === 0) { + return jsonResponse(errorResponse("INTERNAL_ERROR", "Failed to load modules"), 500); + } + + // Filter to locked modules if in lock mode + let modules = result.modules; + if (lockState.locked) { + const lockedNames = new Set(lockState.modules.map((m) => m.toLowerCase())); + modules = modules.filter((m) => lockedNames.has(m.name.toLowerCase())); + } + + // Get status for all modules + const statusChecker = new StatusChecker(); + const statusMap = await statusChecker.checkStatusBatch(modules); + + // Build response + const items: ModuleListItem[] = modules + .sort((a, b) => a.name.localeCompare(b.name)) + .map((m) => { + const statusResult = statusMap.get(m.name.toLowerCase()); + return { + name: m.name, + category: m.category, + description: m.description, + href: m.href, + status: statusResult?.status ?? ModuleStatus.UNKNOWN, + dependsOn: m["depends-on"], + }; + }); + + const response: ModuleListResponse = { + modules: items, + locked: lockState.locked, + lockMessage: lockState.message, + }; + + return jsonResponse(successResponse(response)); +} + +// ============================================================================= +// Get Single Module +// ============================================================================= + +/** + * GET /api/modules/:name - Get single module details + */ +export async function getModule(name: string): Promise { + // Check lock state - only allow access to locked modules when locked + const stateManager = StateManager.getInstance(); + const lockState = await stateManager.getLockState(); + + if (!isModuleAccessibleWhenLocked(name, lockState)) { + return jsonResponse(errorResponse("NOT_FOUND", `Module not found: ${name}`), 404); + } + + const result = await loadModule(name); + + if (!result.success || !result.module) { + return jsonResponse(errorResponse("NOT_FOUND", `Module not found: ${name}`), 404); + } + + const mod = result.module; + + // Get status + const statusChecker = new StatusChecker(); + const statusResult = await statusChecker.checkStatus(mod); + + const detail: ModuleDetail = { + name: mod.name, + category: mod.category, + description: mod.description, + href: mod.href, + status: statusResult.status, + dependsOn: mod["depends-on"], + hasInstallTasks: Array.isArray(mod.install) && mod.install.length > 0, + hasRemoveTasks: Array.isArray(mod.remove) && mod.remove.length > 0, + hasStartTasks: Array.isArray(mod.start) && mod.start.length > 0, + hasStopTasks: Array.isArray(mod.stop) && mod.stop.length > 0, + }; + + return jsonResponse(successResponse(detail)); +} + +// ============================================================================= +// Get Module Status +// ============================================================================= + +/** + * GET /api/modules/:name/status - Get module status + */ +export async function getModuleStatus(name: string): Promise { + // Check lock state - only allow access to locked modules when locked + const stateManager = StateManager.getInstance(); + const lockState = await stateManager.getLockState(); + + if (!isModuleAccessibleWhenLocked(name, lockState)) { + return jsonResponse(errorResponse("NOT_FOUND", `Module not found: ${name}`), 404); + } + + const result = await loadModule(name); + + if (!result.success || !result.module) { + return jsonResponse(errorResponse("NOT_FOUND", `Module not found: ${name}`), 404); + } + + const statusChecker = new StatusChecker(); + const statusResult = await statusChecker.checkStatus(result.module); + + // Get installation info + const installInfo = await stateManager.getModuleInstallInfo(name); + + return jsonResponse( + successResponse({ + module: name, + status: statusResult.status, + installed: statusResult.installed, + running: statusResult.running, + installedAt: installInfo?.installedAt, + }), + ); +} + +// ============================================================================= +// Module Operations (install/remove/start/stop) +// ============================================================================= + +/** + * Start a module operation + */ +async function startOperation(name: string, operation: Operation): Promise { + // Check if module exists + const result = await loadModule(name); + if (!result.success || !result.module) { + return jsonResponse(errorResponse("NOT_FOUND", `Module not found: ${name}`), 404); + } + + // Check lock mode - acts as rudimentary auth + const stateManager = StateManager.getInstance(); + const lockState = await stateManager.getLockState(); + + if (lockState.locked) { + // Block install/remove entirely when locked + if (operation === "install" || operation === "remove") { + return jsonResponse( + errorResponse( + "LOCKED", + lockState.message + ? `System is locked: ${lockState.message}` + : "System is locked. Cannot modify modules.", + ), + 403, + ); + } + + // For start/stop, only allow on locked (installed) modules + if (!isModuleAccessibleWhenLocked(name, lockState)) { + return jsonResponse(errorResponse("NOT_FOUND", `Module not found: ${name}`), 404); + } + } + + // Check for operation in progress + const operationManager = OperationManager.getInstance(); + if (operationManager.hasOperationInProgress(name)) { + return jsonResponse( + errorResponse("OPERATION_IN_PROGRESS", `Operation already in progress for module: ${name}`), + 409, + ); + } + + // Create and start operation + const tracked = await operationManager.createOperation(name, operation); + + const response: OperationResponse = { + operationId: tracked.id, + module: name, + operation, + status: tracked.status, + startedAt: tracked.startedAt.toISOString(), + }; + + return jsonResponse(successResponse(response), 202); +} + +/** + * POST /api/modules/:name/install - Install a module + */ +export async function installModule(name: string): Promise { + return startOperation(name, "install"); +} + +/** + * POST /api/modules/:name/remove - Remove a module + */ +export async function removeModule(name: string): Promise { + return startOperation(name, "remove"); +} + +/** + * POST /api/modules/:name/start - Start a module + */ +export async function startModule(name: string): Promise { + return startOperation(name, "start"); +} + +/** + * POST /api/modules/:name/stop - Stop a module + */ +export async function stopModule(name: string): Promise { + return startOperation(name, "stop"); +} diff --git a/bun/src/server/routes/operations.ts b/bun/src/server/routes/operations.ts new file mode 100644 index 0000000..8a51825 --- /dev/null +++ b/bun/src/server/routes/operations.ts @@ -0,0 +1,91 @@ +/** + * Operation REST API endpoints + */ + +import { OperationManager } from "../operations"; +import { createSSEStream, sendSSEEvent } from "../sse"; +import { errorResponse, jsonResponse, type OperationResponse, successResponse } from "../types"; + +// ============================================================================= +// Get Operation Status +// ============================================================================= + +/** + * GET /api/operations/:id - Get operation status + */ +export async function getOperation(operationId: string): Promise { + const operationManager = OperationManager.getInstance(); + const operation = operationManager.getOperation(operationId); + + if (!operation) { + return jsonResponse(errorResponse("NOT_FOUND", `Operation not found: ${operationId}`), 404); + } + + const response: OperationResponse = { + operationId: operation.id, + module: operation.module, + operation: operation.operation, + status: operation.status, + startedAt: operation.startedAt.toISOString(), + completedAt: operation.completedAt?.toISOString(), + error: operation.error, + }; + + return jsonResponse(successResponse(response)); +} + +// ============================================================================= +// SSE Stream +// ============================================================================= + +/** + * GET /api/operations/:id/stream - Stream operation progress via SSE + */ +export async function streamOperation(operationId: string): Promise { + const operationManager = OperationManager.getInstance(); + const operation = operationManager.getOperation(operationId); + + if (!operation) { + return jsonResponse(errorResponse("NOT_FOUND", `Operation not found: ${operationId}`), 404); + } + + // If operation already completed, send completion event and close + if (operation.status === "completed" || operation.status === "failed") { + return createSSEStream( + (controller) => { + sendSSEEvent(controller, { + type: "complete", + module: operation.module, + operation: operation.operation, + success: operation.status === "completed", + duration: operation.completedAt + ? operation.completedAt.getTime() - operation.startedAt.getTime() + : undefined, + }); + controller.close(); + }, + () => { + // Nothing to cleanup + }, + ); + } + + // Subscribe to operation events + return createSSEStream( + (controller) => { + // Subscribe to future events + operationManager.subscribe(operationId, controller); + + // Send initial status event + sendSSEEvent(controller, { + type: "status", + module: operation.module, + status: operation.status === "running" ? "running" : "not_installed", + }); + }, + (controller) => { + // Unsubscribe on disconnect + operationManager.unsubscribe(operationId, controller); + }, + ); +} diff --git a/bun/src/server/sse.ts b/bun/src/server/sse.ts new file mode 100644 index 0000000..1f359cc --- /dev/null +++ b/bun/src/server/sse.ts @@ -0,0 +1,124 @@ +/** + * Server-Sent Events (SSE) helpers + */ + +import { formatSSEMessage, type SSEEvent } from "../types/events"; + +// ============================================================================= +// Constants +// ============================================================================= + +export const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", // Disable nginx buffering +} as const; + +const DEFAULT_HEARTBEAT_INTERVAL = 30000; // 30 seconds + +// ============================================================================= +// SSE Stream Creation +// ============================================================================= + +export interface SSEStreamOptions { + heartbeatInterval?: number; +} + +/** + * Create an SSE Response with a ReadableStream + */ +export function createSSEStream( + onStart: (controller: ReadableStreamDefaultController) => void, + onClose: (controller: ReadableStreamDefaultController) => void, + options: SSEStreamOptions = {}, +): Response { + const heartbeatInterval = options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL; + const encoder = new TextEncoder(); + let heartbeatTimer: Timer | null = null; + let controllerRef: ReadableStreamDefaultController | null = null; + + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller; + + // Start heartbeat + heartbeatTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(": heartbeat\n\n")); + } catch { + // Stream closed + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + }, heartbeatInterval); + + // Call user's onStart + onStart(controller); + }, + cancel() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + if (controllerRef) { + onClose(controllerRef); + } + }, + }); + + return new Response(stream, { headers: SSE_HEADERS }); +} + +// ============================================================================= +// SSE Event Helpers +// ============================================================================= + +/** + * Send an SSE event to a controller + */ +export function sendSSEEvent( + controller: ReadableStreamDefaultController, + event: SSEEvent, +): boolean { + const encoder = new TextEncoder(); + const message = formatSSEMessage(event); + + try { + controller.enqueue(encoder.encode(message)); + return true; + } catch { + // Stream closed + return false; + } +} + +/** + * Send a raw message to a controller + */ +export function sendSSEMessage( + controller: ReadableStreamDefaultController, + message: string, +): boolean { + const encoder = new TextEncoder(); + + try { + controller.enqueue(encoder.encode(message)); + return true; + } catch { + return false; + } +} + +/** + * Close an SSE stream gracefully + */ +export function closeSSEStream(controller: ReadableStreamDefaultController): void { + try { + controller.close(); + } catch { + // Already closed + } +} diff --git a/bun/src/server/types.ts b/bun/src/server/types.ts new file mode 100644 index 0000000..0fcece7 --- /dev/null +++ b/bun/src/server/types.ts @@ -0,0 +1,165 @@ +/** + * API response types for the REST server + */ + +import { z } from "zod"; +import type { ModuleCategory } from "../types/module"; +import type { Operation } from "../types/plugin"; +import type { ModuleStatus } from "../types/status"; + +// ============================================================================= +// Error Types +// ============================================================================= + +export const ApiErrorCode = z.enum([ + "LOCKED", + "NOT_FOUND", + "OPERATION_IN_PROGRESS", + "VALIDATION_ERROR", + "INTERNAL_ERROR", +]); + +export type ApiErrorCode = z.infer; + +export interface ApiError { + code: ApiErrorCode; + message: string; + details?: unknown; +} + +// ============================================================================= +// Response Envelope +// ============================================================================= + +export interface ApiResponse { + success: boolean; + data?: T; + error?: ApiError; +} + +// ============================================================================= +// Module Types +// ============================================================================= + +export interface ModuleListItem { + name: string; + category: ModuleCategory; + description?: string; + href?: string; + status: ModuleStatus; + dependsOn?: string[]; +} + +export interface ModuleDetail extends ModuleListItem { + hasInstallTasks: boolean; + hasRemoveTasks: boolean; + hasStartTasks: boolean; + hasStopTasks: boolean; +} + +export interface ModuleListResponse { + modules: ModuleListItem[]; + locked: boolean; + lockMessage?: string; +} + +// ============================================================================= +// Operation Types +// ============================================================================= + +export const OperationStatus = z.enum(["queued", "running", "completed", "failed"]); + +export type OperationStatus = z.infer; + +export interface OperationResponse { + operationId: string; + module: string; + operation: Operation; + status: OperationStatus; + startedAt: string; + completedAt?: string; + error?: string; +} + +// ============================================================================= +// Lock Types +// ============================================================================= + +export interface LockStatusResponse { + locked: boolean; + modules: string[]; + lockedAt?: string; + lockedBy?: string; + message?: string; +} + +// ============================================================================= +// Config Types +// ============================================================================= + +export interface ConfigResponse { + domainBase: string; + serverPort: number; + serverHost: string; +} + +// ============================================================================= +// Health Types +// ============================================================================= + +export interface HealthResponse { + status: "ok"; + version: string; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Create a success response + */ +export function successResponse(data: T): ApiResponse { + return { success: true, data }; +} + +/** + * Create an error response + */ +export function errorResponse( + code: ApiErrorCode, + message: string, + details?: unknown, +): ApiResponse { + return { + success: false, + error: { code, message, details }, + }; +} + +/** + * Create a JSON Response with appropriate status code + */ +export function jsonResponse(data: ApiResponse, status = 200): Response { + return Response.json(data, { status }); +} + +/** + * Map error code to HTTP status + */ +export function errorCodeToStatus(code: ApiErrorCode): number { + switch (code) { + case "NOT_FOUND": + return 404; + case "LOCKED": + return 403; + case "OPERATION_IN_PROGRESS": + return 409; + case "VALIDATION_ERROR": + return 400; + case "INTERNAL_ERROR": + return 500; + default: + return 500; + } +} diff --git a/bun/src/types/cert.ts b/bun/src/types/cert.ts new file mode 100644 index 0000000..cfd24f3 --- /dev/null +++ b/bun/src/types/cert.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +/** + * Certificate state schema - tracks initialized certs in /certs/cert-state.yml + */ +export const CertStateSchema = z.object({ + /** Whether certificates have been initialized */ + initialized: z.boolean(), + /** The domain base the certs were generated for (e.g., "test", "abcde.penlabs.net") */ + domainBase: z.string(), + /** ISO timestamp when certs were created */ + createdAt: z.string(), +}); + +export type CertState = z.infer; + +/** + * Certificate paths returned by CertManager + */ +export interface CertPaths { + /** Path to wildcard certificate */ + cert: string; + /** Path to wildcard private key */ + key: string; + /** Path to root CA certificate */ + rootCACert: string; + /** Path to root CA private key */ + rootCAKey: string; +} + +/** + * Certificate file names in the certs directory + */ +export const CERT_FILES = { + ROOT_CA_KEY: "rootCA.key", + ROOT_CA_CERT: "rootCA.crt", + WILDCARD_KEY: "wildcard.key", + WILDCARD_CERT: "wildcard.crt", + WILDCARD_CSR: "wildcard.csr", + WILDCARD_EXT: "wildcard.ext", + STATE_FILE: "cert-state.yml", +} as const; diff --git a/bun/src/types/config.ts b/bun/src/types/config.ts new file mode 100644 index 0000000..45d2210 --- /dev/null +++ b/bun/src/types/config.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +/** + * Server configuration schema + */ +export const ServerConfigSchema = z.object({ + /** Port to listen on */ + port: z.number().int().min(1).max(65535).default(8087), + /** Host to bind to */ + host: z.string().default("127.0.0.1"), + /** Enable CORS for development */ + cors: z.boolean().default(false), +}); + +export type ServerConfig = z.infer; + +/** + * Logging configuration schema + */ +export const LogConfigSchema = z.object({ + /** Log level */ + level: z.enum(["debug", "info", "warn", "error"]).default("info"), + /** Log format: json for production, pretty for development */ + format: z.enum(["json", "pretty"]).default("pretty"), + /** Log file path (optional, logs to stdout if not set) */ + file: z.string().optional(), +}); + +export type LogConfig = z.infer; + +/** + * Main configuration schema for /etc/katana/config.yml + */ +export const ConfigSchema = z.object({ + /** Path to modules directory (resolved dynamically if not set) */ + modulesPath: z.string().optional(), + /** GitHub repository URL for fetching modules */ + modulesRepo: z.string().default("https://github.com/SamuraiWTF/katana"), + /** Git branch to use when fetching/updating modules */ + modulesBranch: z.string().default("main"), + /** Path to state directory (installed.yml, katana.lock) */ + statePath: z.string().default("/var/lib/katana"), + /** Base domain for module URLs (e.g., 'test' -> dvwa.test) */ + domainBase: z.string().default("test"), + /** Server configuration */ + server: ServerConfigSchema.optional().transform((val) => ServerConfigSchema.parse(val ?? {})), + /** Logging configuration */ + log: LogConfigSchema.optional().transform((val) => LogConfigSchema.parse(val ?? {})), +}); + +export type Config = z.infer; + +/** + * Default configuration values + */ +export const DEFAULT_CONFIG: Config = ConfigSchema.parse({}); + +/** + * Configuration file paths in order of precedence + */ +export const CONFIG_PATHS = [ + "/etc/katana/config.yml", + "~/.config/katana/config.yml", + "./config.yml", +] as const; + +/** + * Default user data directory for modules (used when modulesPath not configured) + */ +export const DEFAULT_USER_DATA_DIR = "~/.local/share/katana"; + +/** + * Default modules subdirectory name + */ +export const MODULES_SUBDIR = "modules"; diff --git a/bun/src/types/events.ts b/bun/src/types/events.ts new file mode 100644 index 0000000..00f7a80 --- /dev/null +++ b/bun/src/types/events.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { ModuleStatus } from "./status"; + +/** + * SSE event types for streaming operation progress + */ +export const SSEEventType = z.enum(["progress", "log", "status", "complete", "error"]); + +export type SSEEventType = z.infer; + +/** + * Progress event - indicates task completion progress + */ +export const ProgressEventSchema = z.object({ + type: z.literal("progress"), + task: z.string(), + current: z.number().int().nonnegative(), + total: z.number().int().positive(), +}); + +export type ProgressEvent = z.infer; + +/** + * Log event - a log message from the operation + */ +export const LogEventSchema = z.object({ + type: z.literal("log"), + level: z.enum(["debug", "info", "warn", "error"]), + message: z.string(), + timestamp: z.string().datetime().optional(), +}); + +export type LogEvent = z.infer; + +/** + * Status event - module status has changed + */ +export const StatusEventSchema = z.object({ + type: z.literal("status"), + module: z.string(), + status: z.enum([ + ModuleStatus.NOT_INSTALLED, + ModuleStatus.INSTALLED, + ModuleStatus.STOPPED, + ModuleStatus.RUNNING, + ModuleStatus.BLOCKED, + ModuleStatus.UNKNOWN, + ]), +}); + +export type StatusEvent = z.infer; + +/** + * Complete event - operation finished + */ +export const CompleteEventSchema = z.object({ + type: z.literal("complete"), + module: z.string(), + operation: z.enum(["install", "remove", "start", "stop"]), + success: z.boolean(), + duration: z.number().nonnegative().optional(), +}); + +export type CompleteEvent = z.infer; + +/** + * Error event - operation encountered an error + */ +export const ErrorEventSchema = z.object({ + type: z.literal("error"), + message: z.string(), + details: z.string().optional(), + task: z.string().optional(), +}); + +export type ErrorEvent = z.infer; + +/** + * Union of all SSE event types + */ +export const SSEEventSchema = z.discriminatedUnion("type", [ + ProgressEventSchema, + LogEventSchema, + StatusEventSchema, + CompleteEventSchema, + ErrorEventSchema, +]); + +export type SSEEvent = z.infer; + +/** + * Create an SSE-formatted message string from an event + */ +export function formatSSEMessage(event: SSEEvent): string { + const data = JSON.stringify(event); + return `event: ${event.type}\ndata: ${data}\n\n`; +} diff --git a/bun/src/types/index.ts b/bun/src/types/index.ts new file mode 100644 index 0000000..b3b1c2e --- /dev/null +++ b/bun/src/types/index.ts @@ -0,0 +1,139 @@ +// Module status + +// Config manager +export { + ConfigManager, + type ConfigManagerOptions, + getConfigManager, +} from "../core/config-manager"; +// Module loader types (re-exported for convenience) +export type { + LoadedModule, + ModuleLoadError, + ModuleLoaderOptions, + ModuleLoaderResult, + ModuleLoadResult, +} from "../core/module-loader"; +// State manager +export { + getStateManager, + type LockOptions, + StateManager, + type StateManagerOptions, +} from "../core/state-manager"; +// Configuration +export { + CONFIG_PATHS, + type Config, + ConfigSchema, + DEFAULT_CONFIG, + type LogConfig, + LogConfigSchema, + type ServerConfig, + ServerConfigSchema, +} from "./config"; +// SSE events +export { + type CompleteEvent, + CompleteEventSchema, + type ErrorEvent, + ErrorEventSchema, + formatSSEMessage, + type LogEvent, + LogEventSchema, + type ProgressEvent, + ProgressEventSchema, + type SSEEvent, + SSEEventSchema, + SSEEventType, + type StatusEvent, + StatusEventSchema, +} from "./events"; +// Module YAML schema +export { + type CommandParams, + CommandParamsSchema, + CommandTaskSchema, + type CopyParams, + CopyParamsSchema, + CopyTaskSchema, + type DesktopFile, + DesktopFileSchema, + type DesktopParams, + DesktopParamsSchema, + DesktopTaskSchema, + type DockerParams, + DockerParamsSchema, + DockerTaskSchema, + type ExistsCheck, + // Status schemas + ExistsCheckSchema, + type FileParams, + FileParamsSchema, + FileTaskSchema, + formatModuleError, + type GetUrlParams, + GetUrlParamsSchema, + GetUrlTaskSchema, + type GitParams, + GitParamsSchema, + GitTaskSchema, + type LineinfileParams, + LineinfileParamsSchema, + LineinfileTaskSchema, + type Module, + // Module schema + ModuleCategory, + ModuleSchema, + // Helpers + parseModule, + type ReplaceParams, + ReplaceParamsSchema, + ReplaceTaskSchema, + type ReverseproxyParams, + ReverseproxyParamsSchema, + ReverseproxyTaskSchema, + type RmParams, + RmParamsSchema, + RmTaskSchema, + // Types + type ServiceParams, + // Task parameter schemas + ServiceParamsSchema, + // Task schemas + ServiceTaskSchema, + type StartedCheck, + StartedCheckSchema, + type Status, + StatusSchema, + safeParseModule, + type Task, + TaskSchema, + type UnarchiveParams, + UnarchiveParamsSchema, + UnarchiveTaskSchema, +} from "./module"; +// Plugin types +export { + BasePlugin, + type ExecutionContext, + type IPlugin, + type Logger, + type Operation, + type PluginResult, + PluginResultSchema, +} from "./plugin"; +// State files +export { + EMPTY_INSTALLED_STATE, + EMPTY_LOCK_STATE, + type InstalledModule, + InstalledModuleSchema, + type InstalledState, + InstalledStateSchema, + LockFileLegacySchema, + type LockFileYaml, + LockFileYamlSchema, + type LockState, +} from "./state"; +export { isModuleStatus, ModuleStatus } from "./status"; diff --git a/bun/src/types/module.ts b/bun/src/types/module.ts new file mode 100644 index 0000000..ff10a84 --- /dev/null +++ b/bun/src/types/module.ts @@ -0,0 +1,408 @@ +import { z } from "zod"; + +// ============================================================================= +// Task Parameter Schemas +// ============================================================================= + +/** + * Service task - manage systemd services + * Example: { name: "docker", state: "running" } + */ +export const ServiceParamsSchema = z.object({ + name: z.string().min(1), + state: z.enum(["running", "stopped", "restarted"]), +}); + +export type ServiceParams = z.infer; + +/** + * Docker task - manage Docker containers + * Example: { name: "dvwa", image: "vulnerables/web-dvwa", ports: { "80/tcp": 31000 } } + */ +export const DockerParamsSchema = z.object({ + name: z.string().min(1), + image: z.string().optional(), + ports: z.record(z.string(), z.number().int().positive()).optional(), +}); + +export type DockerParams = z.infer; + +/** + * Lineinfile task - add/remove lines in files + * Example: { dest: "/etc/hosts", line: "127.0.0.1 dvwa.test", state: "present" } + */ +export const LineinfileParamsSchema = z.object({ + dest: z.string().min(1), + line: z.string(), + state: z.enum(["present", "absent"]).default("present"), +}); + +export type LineinfileParams = z.infer; + +/** + * Reverseproxy task - manage nginx reverse proxy configs + * Example: { hostname: "dvwa.test", proxy_pass: "http://localhost:31000" } + */ +export const ReverseproxyParamsSchema = z.object({ + hostname: z.string().min(1), + proxy_pass: z.string().optional(), +}); + +export type ReverseproxyParams = z.infer; + +/** + * File task - create/remove directories + * Example: { path: "/opt/samurai/burpsuite", state: "directory" } + */ +export const FileParamsSchema = z.object({ + path: z.string().min(1), + state: z.enum(["directory", "absent"]), +}); + +export type FileParams = z.infer; + +/** + * Copy task - write content to files + * Example: { dest: "/usr/bin/burp", content: "#!/bin/bash\n...", mode: "0755" } + * Note: mode must be a string to avoid YAML octal parsing issues + */ +export const CopyParamsSchema = z.object({ + dest: z.string().min(1), + content: z.string(), + mode: z + .string() + .regex(/^0?[0-7]{3,4}$/) + .optional(), +}); + +export type CopyParams = z.infer; + +/** + * GetUrl task - download files from URLs + * Example: { url: "https://example.com/file.jar", dest: "/opt/app/file.jar" } + */ +export const GetUrlParamsSchema = z.object({ + url: z.string().url(), + dest: z.string().min(1), +}); + +export type GetUrlParams = z.infer; + +/** + * Git task - clone repositories + * Example: { repo: "https://github.com/user/repo.git", dest: "/opt/repo" } + */ +export const GitParamsSchema = z.object({ + repo: z.string().url(), + dest: z.string().min(1), +}); + +export type GitParams = z.infer; + +/** + * Command task - run shell commands + * Example: { cmd: "docker compose up -d", cwd: "/opt/app", shell: true } + */ +export const CommandParamsSchema = z.object({ + cmd: z.string().min(1), + cwd: z.string().optional(), + unsafe: z.boolean().optional(), + shell: z.boolean().optional(), +}); + +export type CommandParams = z.infer; + +/** + * Replace task - regex-based text replacement in files + * Example: { path: "/etc/config", regexp: "old_value", replace: "new_value" } + */ +export const ReplaceParamsSchema = z.object({ + path: z.string().min(1), + regexp: z.string().min(1), + replace: z.string(), +}); + +export type ReplaceParams = z.infer; + +/** + * Rm task - remove files or directories + * Example: { path: "/tmp/old-file" } or { path: ["/tmp/a", "/tmp/b"] } + */ +export const RmParamsSchema = z.object({ + path: z.union([z.string().min(1), z.array(z.string().min(1))]), +}); + +export type RmParams = z.infer; + +/** + * Unarchive task - download and extract tar.gz files + * Example: { url: "https://example.com/app.tar.gz", dest: "/opt/app", cleanup: true } + */ +export const UnarchiveParamsSchema = z.object({ + url: z.string().url(), + dest: z.string().min(1), + cleanup: z.boolean().optional(), +}); + +export type UnarchiveParams = z.infer; + +/** + * Desktop file configuration for DesktopIntegration plugin + */ +export const DesktopFileSchema = z.object({ + filename: z.string().min(1), + content: z.string(), + add_to_favorites: z.boolean().optional(), +}); + +export type DesktopFile = z.infer; + +/** + * Desktop task - manage desktop integration (menu items, favorites) + * For install: { desktop_file: { filename, content, add_to_favorites } } + * For remove: { filename: "app.desktop" } + */ +export const DesktopParamsSchema = z.object({ + desktop_file: DesktopFileSchema.optional(), + filename: z.string().optional(), +}); + +export type DesktopParams = z.infer; + +// ============================================================================= +// Task Schema (discriminated by action key) +// ============================================================================= + +/** + * Base task fields shared by all task types + */ +const TaskBaseSchema = z.object({ + /** Optional human-readable description of this task */ + name: z.string().optional(), +}); + +/** + * Individual task schemas with their action key + */ +export const ServiceTaskSchema = TaskBaseSchema.extend({ + service: ServiceParamsSchema, +}); + +export const DockerTaskSchema = TaskBaseSchema.extend({ + docker: DockerParamsSchema, +}); + +export const LineinfileTaskSchema = TaskBaseSchema.extend({ + lineinfile: LineinfileParamsSchema, +}); + +export const ReverseproxyTaskSchema = TaskBaseSchema.extend({ + reverseproxy: ReverseproxyParamsSchema, +}); + +export const FileTaskSchema = TaskBaseSchema.extend({ + file: FileParamsSchema, +}); + +export const CopyTaskSchema = TaskBaseSchema.extend({ + copy: CopyParamsSchema, +}); + +export const GetUrlTaskSchema = TaskBaseSchema.extend({ + get_url: GetUrlParamsSchema, +}); + +export const GitTaskSchema = TaskBaseSchema.extend({ + git: GitParamsSchema, +}); + +export const CommandTaskSchema = TaskBaseSchema.extend({ + command: CommandParamsSchema, +}); + +export const ReplaceTaskSchema = TaskBaseSchema.extend({ + replace: ReplaceParamsSchema, +}); + +export const RmTaskSchema = TaskBaseSchema.extend({ + rm: RmParamsSchema, +}); + +export const UnarchiveTaskSchema = TaskBaseSchema.extend({ + unarchive: UnarchiveParamsSchema, +}); + +export const DesktopTaskSchema = TaskBaseSchema.extend({ + desktop: DesktopParamsSchema, +}); + +/** + * Union of all task types + * Each task has an optional `name` field plus exactly one action key + */ +export const TaskSchema = z.union([ + ServiceTaskSchema, + DockerTaskSchema, + LineinfileTaskSchema, + ReverseproxyTaskSchema, + FileTaskSchema, + CopyTaskSchema, + GetUrlTaskSchema, + GitTaskSchema, + CommandTaskSchema, + ReplaceTaskSchema, + RmTaskSchema, + UnarchiveTaskSchema, + DesktopTaskSchema, +]); + +export type Task = z.infer; + +// ============================================================================= +// Status Check Schemas +// ============================================================================= + +/** + * Exists check - verify resource existence for status.installed.exists + * Example: { docker: "dvwa" } or { path: "/opt/app" } or { service: "nginx" } + */ +export const ExistsCheckSchema = z.object({ + docker: z.string().optional(), + path: z.string().optional(), + service: z.string().optional(), +}); + +export type ExistsCheck = z.infer; + +/** + * Started check - verify resource is running for status.running.started + * Example: { docker: "dvwa" } or { service: "nginx" } + */ +export const StartedCheckSchema = z.object({ + docker: z.string().optional(), + service: z.string().optional(), +}); + +export type StartedCheck = z.infer; + +/** + * Status section schema + * Example: + * status: + * running: + * started: + * docker: dvwa + * installed: + * exists: + * docker: dvwa + */ +export const StatusSchema = z.object({ + running: z + .object({ + started: StartedCheckSchema, + }) + .optional(), + installed: z + .object({ + exists: ExistsCheckSchema, + }) + .optional(), +}); + +export type Status = z.infer; + +// ============================================================================= +// Module Schema +// ============================================================================= + +/** + * Module categories + */ +export const ModuleCategory = z.enum(["targets", "tools", "base", "management"]); + +export type ModuleCategory = z.infer; + +/** + * Complete module YAML schema + * + * Example module: + * ```yaml + * name: dvwa + * category: targets + * description: A classic test lab focused on OWASP top 10 vulnerabilities. + * href: https://dvwa.test:8443 + * + * install: + * - service: + * name: docker + * state: running + * - docker: + * name: dvwa + * image: vulnerables/web-dvwa + * ports: + * 80/tcp: 31000 + * + * status: + * running: + * started: + * docker: dvwa + * installed: + * exists: + * docker: dvwa + * ``` + */ +export const ModuleSchema = z.object({ + /** Unique identifier for the module */ + name: z.string().min(1), + /** Category grouping */ + category: ModuleCategory, + /** Human-readable description */ + description: z.string().optional(), + /** URL to access the module when running (for targets) */ + href: z.string().url().optional(), + /** Module dependencies - must be installed before this module */ + "depends-on": z.array(z.string().min(1)).optional(), + /** Tasks to run when installing the module */ + install: z.array(TaskSchema).optional(), + /** Tasks to run when removing the module */ + remove: z.array(TaskSchema).optional(), + /** Tasks to run when starting the module */ + start: z.array(TaskSchema).optional(), + /** Tasks to run when stopping the module */ + stop: z.array(TaskSchema).optional(), + /** Status checks to determine module state */ + status: StatusSchema.optional(), +}); + +export type Module = z.infer; + +// ============================================================================= +// Validation Helpers +// ============================================================================= + +/** + * Parse and validate a module object + * Returns the validated module or throws a ZodError with human-readable messages + */ +export function parseModule(data: unknown): Module { + return ModuleSchema.parse(data); +} + +/** + * Safely parse a module, returning success/error result + */ +export function safeParseModule(data: unknown): ReturnType { + return ModuleSchema.safeParse(data); +} + +/** + * Get a human-readable error message from a Zod error + */ +export function formatModuleError(error: z.ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join("\n"); +} diff --git a/bun/src/types/plugin.ts b/bun/src/types/plugin.ts new file mode 100644 index 0000000..709204a --- /dev/null +++ b/bun/src/types/plugin.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +/** + * Result returned by a plugin action (install, remove, start, stop) + */ +export const PluginResultSchema = z.object({ + success: z.boolean(), + message: z.string().optional(), + changed: z.boolean().default(false), +}); + +export type PluginResult = z.infer; + +/** + * Operation type indicating which module section is being executed + */ +export type Operation = "install" | "remove" | "start" | "stop"; + +/** + * Context provided to plugins during execution + */ +export interface ExecutionContext { + /** When true, plugin should simulate actions without making changes */ + mock: boolean; + /** When true, plugin should log what it would do without executing */ + dryRun: boolean; + /** Logger instance for plugin output */ + logger: Logger; + /** The operation being performed (install/remove/start/stop) */ + operation: Operation; +} + +/** + * Simple logger interface for plugin output + */ +export interface Logger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +/** + * Plugin interface for implementing task handlers. + * + * Plugins handle specific task types (docker, service, lineinfile, etc.) + * and provide methods for both execution and status checking. + */ +export interface IPlugin { + /** Unique name identifying this plugin (e.g., 'docker', 'service') */ + readonly name: string; + + /** + * Execute the plugin's primary action. + * The operation (install/remove/start/stop) is determined by context. + */ + execute(params: unknown, context: ExecutionContext): Promise; + + /** + * Check if the resource managed by this plugin exists. + * Used for status.installed.exists checks. + */ + exists?(params: unknown): Promise; + + /** + * Check if the resource managed by this plugin is running/started. + * Used for status.running.started checks. + */ + started?(params: unknown): Promise; +} + +/** + * Base class for plugins providing common functionality. + * Concrete plugins should extend this class. + */ +export abstract class BasePlugin implements IPlugin { + abstract readonly name: string; + + abstract execute(params: unknown, context: ExecutionContext): Promise; + + /** + * Helper to create a successful result + */ + protected success(message?: string, changed = true): PluginResult { + return { success: true, message, changed }; + } + + /** + * Helper to create a failure result + */ + protected failure(message: string): PluginResult { + return { success: false, message, changed: false }; + } + + /** + * Helper to create a no-op result (success but no changes made) + */ + protected noop(message?: string): PluginResult { + return { success: true, message, changed: false }; + } +} diff --git a/bun/src/types/state.ts b/bun/src/types/state.ts new file mode 100644 index 0000000..a4d27eb --- /dev/null +++ b/bun/src/types/state.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +/** + * Entry for a single installed module in installed.yml + */ +export const InstalledModuleSchema = z.object({ + /** ISO timestamp when the module was installed */ + installedAt: z.string().datetime().optional(), + /** Version of the module (if versioning is implemented) */ + version: z.string().optional(), +}); + +export type InstalledModule = z.infer; + +/** + * Schema for installed.yml - tracks which modules are installed + */ +export const InstalledStateSchema = z.object({ + /** Map of module name to installation metadata */ + modules: z.record(z.string(), InstalledModuleSchema).default({}), +}); + +export type InstalledState = z.infer; + +/** + * New YAML format for katana.lock with metadata + */ +export const LockFileYamlSchema = z.object({ + /** Whether lock mode is enabled */ + locked: z.boolean(), + /** List of modules that were installed when lock was enabled */ + modules: z.array(z.string()), + /** ISO timestamp when lock was enabled */ + lockedAt: z.string().datetime().optional(), + /** Username or identifier of who enabled the lock */ + lockedBy: z.string().optional(), + /** Optional message explaining why the lock was enabled */ + message: z.string().optional(), +}); + +export type LockFileYaml = z.infer; + +/** + * Legacy format: newline-separated list of module names + * This is detected by checking if the content is a plain string without YAML structure + */ +export const LockFileLegacySchema = z.string().transform((val) => { + const modules = val + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return { + locked: modules.length > 0, + modules, + } satisfies Partial; +}); + +/** + * Parsed lock file state (normalized from either format) + */ +export interface LockState { + locked: boolean; + modules: string[]; + lockedAt?: string; + lockedBy?: string; + message?: string; +} + +/** + * Default empty lock state + */ +export const EMPTY_LOCK_STATE: LockState = { + locked: false, + modules: [], +}; + +/** + * Default empty installed state + */ +export const EMPTY_INSTALLED_STATE: InstalledState = { + modules: {}, +}; diff --git a/bun/src/types/status.ts b/bun/src/types/status.ts new file mode 100644 index 0000000..4637240 --- /dev/null +++ b/bun/src/types/status.ts @@ -0,0 +1,20 @@ +/** + * Module status enum representing the lifecycle states of a module. + */ +export const ModuleStatus = { + NOT_INSTALLED: "not_installed", + INSTALLED: "installed", + STOPPED: "stopped", + RUNNING: "running", + BLOCKED: "blocked", + UNKNOWN: "unknown", +} as const; + +export type ModuleStatus = (typeof ModuleStatus)[keyof typeof ModuleStatus]; + +/** + * Check if a value is a valid ModuleStatus + */ +export function isModuleStatus(value: unknown): value is ModuleStatus { + return typeof value === "string" && Object.values(ModuleStatus).includes(value as ModuleStatus); +} diff --git a/bun/systemd/katana.service b/bun/systemd/katana.service new file mode 100644 index 0000000..772ef72 --- /dev/null +++ b/bun/systemd/katana.service @@ -0,0 +1,24 @@ +[Unit] +Description=Katana Module Management Server +After=network.target docker.service +Wants=docker.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/katana serve --tls --host 0.0.0.0 +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/katana /etc/nginx/sites-available /etc/nginx/sites-enabled + +# Environment +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target diff --git a/bun/tests/unit/cli.test.ts b/bun/tests/unit/cli.test.ts new file mode 100644 index 0000000..194d4a2 --- /dev/null +++ b/bun/tests/unit/cli.test.ts @@ -0,0 +1,495 @@ +import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; + +const BUN_DIR = resolve(import.meta.dir, "..", ".."); +const MODULES_DIR = resolve(BUN_DIR, "..", "modules"); +const BUN_BIN = resolve(process.env.HOME || "", ".bun", "bin", "bun"); + +// Helper to run CLI commands +async function cli(args: string): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + const proc = Bun.spawn([BUN_BIN, "run", "src/cli.ts", ...args.split(" ")], { + cwd: BUN_DIR, + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + return { stdout, stderr, exitCode }; +} + +// Track temp files for cleanup +const tempFiles: string[] = []; + +afterAll(async () => { + for (const file of tempFiles) { + try { + (await Bun.file(file).exists()) && (await Bun.write(file, "")); + } catch { + // Ignore cleanup errors + } + } +}); + +// ============================================================================= +// list command +// ============================================================================= + +describe("list command", () => { + test("lists all modules", async () => { + const result = await cli("list"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("CATEGORY"); + expect(result.stdout).toContain("DESCRIPTION"); + expect(result.stdout).toContain("Total:"); + expect(result.stdout).toContain("module(s)"); + }); + + test("filters by targets category", async () => { + const result = await cli("list targets"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("targets"); + expect(result.stdout).toContain("dvwa"); + // Should not contain other categories + expect(result.stdout).not.toMatch(/\btools\b/); + }); + + test("filters by tools category", async () => { + const result = await cli("list tools"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("tools"); + // Should not contain other categories + expect(result.stdout).not.toMatch(/\btargets\b/); + }); + + test("shows no modules message for invalid category", async () => { + const result = await cli("list invalid_category"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("No modules found"); + }); + + test( + "shows warning when some modules fail to load", + async () => { + // Create an invalid module file temporarily + const invalidFile = resolve(MODULES_DIR, "targets", "_test_invalid_module.yml"); + tempFiles.push(invalidFile); + await Bun.write(invalidFile, "name: test\ncategory: invalid_category_xyz"); + + try { + const result = await cli("list"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Total:"); + expect(result.stderr).toContain("Warning:"); + expect(result.stderr).toContain("module(s) failed to load"); + } finally { + // Clean up immediately + (await Bun.file(invalidFile).exists()) && (await Bun.$`rm ${invalidFile}`.quiet()); + } + }, + { timeout: 10000 }, + ); +}); + +// ============================================================================= +// validate command +// ============================================================================= + +describe("validate command", () => { + test("validates a valid module file", async () => { + const dvwaPath = resolve(MODULES_DIR, "targets", "dvwa.yml"); + const result = await cli(`validate ${dvwaPath}`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Valid:"); + expect(result.stdout).toContain("dvwa"); + expect(result.stdout).toContain("targets"); + }); + + test("errors on non-existent file", async () => { + const result = await cli("validate /nonexistent/path/file.yml"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("File Read Error"); + expect(result.stderr).toContain("File not found"); + }); + + test("errors on invalid YAML syntax", async () => { + const tempFile = `/tmp/cli-test-invalid-yaml-${Date.now()}.yml`; + tempFiles.push(tempFile); + await Bun.write(tempFile, "name: test\n invalid: indentation"); + + const result = await cli(`validate ${tempFile}`); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("YAML Parse Error"); + }); + + test("errors on invalid module schema", async () => { + const tempFile = `/tmp/cli-test-invalid-schema-${Date.now()}.yml`; + tempFiles.push(tempFile); + await Bun.write( + tempFile, + ` +name: test +category: invalid_category_value +`, + ); + + const result = await cli(`validate ${tempFile}`); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Validation Error"); + }); +}); + +// ============================================================================= +// status command +// ============================================================================= + +describe("status command", () => { + test("shows status for existing module", async () => { + const result = await cli("status dvwa"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Module: dvwa"); + expect(result.stdout).toContain("Category: targets"); + // Status can vary based on system state, just check it contains "Status:" + expect(result.stdout).toContain("Status:"); + }); + + test("finds module with case-insensitive name", async () => { + const result = await cli("status DVWA"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Module: dvwa"); + expect(result.stdout).toContain("Category: targets"); + }); + + test("errors on non-existent module", async () => { + const result = await cli("status nonexistent_module_xyz"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Module not found"); + }); +}); + +// ============================================================================= +// lock/unlock commands +// ============================================================================= + +describe("lock command", () => { + test("enables lock mode", async () => { + // Ensure unlocked before testing + await cli("unlock"); + + const result = await cli("lock"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Lock mode enabled"); + }); + + test("enables lock mode with message", async () => { + // First unlock in case previous test left it locked + await cli("unlock"); + + // Note: cli() splits on spaces, so use a message without spaces + const result = await cli("lock -m ProductionDeployment"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Lock mode enabled"); + }); + + test("shows message when already locked", async () => { + // Ensure locked + await cli("lock"); + + const result = await cli("lock"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("already locked"); + }); +}); + +describe("unlock command", () => { + test("disables lock mode", async () => { + // Ensure locked first + await cli("lock"); + + const result = await cli("unlock"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Lock mode disabled"); + }); + + test("shows message when not locked", async () => { + // Ensure unlocked + await cli("unlock"); + + const result = await cli("unlock"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("not locked"); + }); +}); + +// ============================================================================= +// init command +// ============================================================================= + +describe("init command", () => { + test("creates config file with defaults (non-interactive)", async () => { + const tempFile = `/tmp/katana-init-test-${Date.now()}.yml`; + tempFiles.push(tempFile); + + const result = await cli(`init --non-interactive --path ${tempFile}`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Configuration saved"); + expect(result.stdout).toContain("Domain base:"); + expect(result.stdout).toContain("Server port:"); + + // Verify file was created + const content = await Bun.file(tempFile).text(); + expect(content).toContain("domainBase:"); + expect(content).toContain("server:"); + }); + + test("respects --domain-base option", async () => { + const tempFile = `/tmp/katana-init-test-domain-${Date.now()}.yml`; + tempFiles.push(tempFile); + + const result = await cli(`init --non-interactive --path ${tempFile} --domain-base=custom`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Domain base: custom"); + + const content = await Bun.file(tempFile).text(); + expect(content).toContain("domainBase: custom"); + }); + + test("respects --port option", async () => { + const tempFile = `/tmp/katana-init-test-port-${Date.now()}.yml`; + tempFiles.push(tempFile); + + const result = await cli(`init --non-interactive --path ${tempFile} --port 9000`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Server port: 9000"); + + const content = await Bun.file(tempFile).text(); + expect(content).toContain("port: 9000"); + }); + + test("errors when file exists without --force", async () => { + const tempFile = `/tmp/katana-init-test-exists-${Date.now()}.yml`; + tempFiles.push(tempFile); + await Bun.write(tempFile, "existing: content"); + + const result = await cli(`init --non-interactive --path ${tempFile}`); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("already exists"); + expect(result.stderr).toContain("--force"); + }); + + test("overwrites with --force flag", async () => { + const tempFile = `/tmp/katana-init-test-force-${Date.now()}.yml`; + tempFiles.push(tempFile); + await Bun.write(tempFile, "existing: content"); + + const result = await cli(`init --non-interactive --path ${tempFile} --force`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Configuration saved"); + + const content = await Bun.file(tempFile).text(); + expect(content).toContain("domainBase:"); + expect(content).not.toContain("existing:"); + }); + + test("creates parent directories if needed", async () => { + const tempDir = `/tmp/katana-init-test-dir-${Date.now()}`; + const tempFile = `${tempDir}/subdir/config.yml`; + tempFiles.push(tempFile); + + const result = await cli(`init --non-interactive --path ${tempFile}`); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Configuration saved"); + + // Cleanup + await Bun.$`rm -rf ${tempDir}`.quiet(); + }); +}); + +// ============================================================================= +// list command lock mode +// ============================================================================= + +describe("list command - lock mode", () => { + // Ensure clean lock state before and after each test + beforeEach(async () => { + await cli("unlock"); + }); + + afterEach(async () => { + await cli("unlock"); + }); + + test( + "shows lock banner when locked", + async () => { + await cli("lock -m TestLockMessage"); + + const result = await cli("list"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("[LOCKED]"); + expect(result.stdout).toContain("TestLockMessage"); + }, + { timeout: 10000 }, + ); + + test( + "shows lock banner without message", + async () => { + await cli("lock"); + + const result = await cli("list"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("[LOCKED]"); + expect(result.stdout).toContain("System is locked"); + }, + { timeout: 10000 }, + ); + + test( + "filters to installed modules when locked", + async () => { + // Lock with no installed modules + await cli("lock"); + + const result = await cli("list"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("[LOCKED]"); + // Since no modules are installed, should show "No installed modules" + expect(result.stdout).toContain("No installed modules"); + }, + { timeout: 10000 }, + ); + + test( + "shows all modules when not locked", + async () => { + const result = await cli("list"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("[LOCKED]"); + expect(result.stdout).toContain("dvwa"); // Should show available modules + }, + { timeout: 10000 }, + ); +}); + +// ============================================================================= +// update command +// ============================================================================= + +describe("update command", () => { + test("update fetches modules from GitHub", async () => { + const result = await cli("update"); + + // Should succeed and show progress + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Updating modules..."); + expect(result.stdout).toContain("Repository:"); + expect(result.stdout).toContain("Branch:"); + }); + + test("update accepts --branch option", async () => { + const result = await cli("update --branch main"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Branch: main"); + }); +}); + +// ============================================================================= +// module operation commands (install, remove, start, stop) +// ============================================================================= + +describe("module operation commands", () => { + test("install fails for non-existent module", async () => { + const result = await cli("install nonexistent"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Module not found"); + }); + + test("remove fails for non-existent module", async () => { + const result = await cli("remove nonexistent"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Module not found"); + }); + + test("start fails for non-existent module", async () => { + const result = await cli("start nonexistent"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Module not found"); + }); + + test("stop fails for non-existent module", async () => { + const result = await cli("stop nonexistent"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Module not found"); + }); + + test("install shows dry-run option in help", async () => { + const result = await cli("install --help"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--dry-run"); + }); +}); + +// ============================================================================= +// help and version +// ============================================================================= + +describe("help and version", () => { + test("--help shows usage information", async () => { + const result = await cli("--help"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage:"); + expect(result.stdout).toContain("katana"); + expect(result.stdout).toContain("list"); + expect(result.stdout).toContain("validate"); + expect(result.stdout).toContain("status"); + }); + + test("--version shows version number", async () => { + const result = await cli("--version"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("0.1.0"); + }); +}); diff --git a/bun/tests/unit/core/config-manager.test.ts b/bun/tests/unit/core/config-manager.test.ts new file mode 100644 index 0000000..246ee41 --- /dev/null +++ b/bun/tests/unit/core/config-manager.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { stringify as yamlStringify } from "yaml"; +import { ConfigManager, getConfigManager } from "../../../src/core/config-manager"; +import { DEFAULT_CONFIG } from "../../../src/types/config"; + +// Use crypto.randomUUID for guaranteed uniqueness +const createTempDir = () => `/tmp/katana-config-test-${crypto.randomUUID()}`; + +describe("ConfigManager", () => { + let tempDir: string; + + beforeEach(async () => { + ConfigManager.resetInstance(); + tempDir = createTempDir(); + await Bun.$`mkdir -p ${tempDir}`.quiet(); + }); + + afterEach(async () => { + ConfigManager.resetInstance(); + try { + await Bun.$`rm -rf ${tempDir}`.quiet(); + } catch { + // Ignore cleanup errors + } + }); + + describe("singleton", () => { + test("getInstance returns same instance", () => { + ConfigManager.resetInstance(); + const instance1 = ConfigManager.getInstance(); + const instance2 = ConfigManager.getInstance(); + + expect(instance1).toBe(instance2); + }); + + test("resetInstance clears singleton", () => { + ConfigManager.resetInstance(); + const instance1 = ConfigManager.getInstance(); + ConfigManager.resetInstance(); + const instance2 = ConfigManager.getInstance(); + + expect(instance1).not.toBe(instance2); + }); + }); + + describe("loadConfig", () => { + test("returns DEFAULT_CONFIG when no config file exists", async () => { + const manager = new ConfigManager({ + configPaths: [join(tempDir, "nonexistent.yml")], + }); + + const config = await manager.loadConfig(); + + expect(config).toEqual(DEFAULT_CONFIG); + expect(manager.getConfigPath()).toBeNull(); + }); + + test("loads config from first existing path", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + domainBase: "custom", + modulesPath: "/custom/modules", + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config = await manager.loadConfig(); + + expect(config.domainBase).toBe("custom"); + expect(config.modulesPath).toBe("/custom/modules"); + expect(manager.getConfigPath()).toBe(configPath); + }); + + test("uses second path if first doesn't exist", async () => { + const configPath1 = join(tempDir, "config1.yml"); + const configPath2 = join(tempDir, "config2.yml"); + + await Bun.write( + configPath2, + yamlStringify({ + domainBase: "from-second", + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath1, configPath2], + }); + + const config = await manager.loadConfig(); + + expect(config.domainBase).toBe("from-second"); + expect(manager.getConfigPath()).toBe(configPath2); + }); + + test("applies default values for missing fields", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + domainBase: "custom", + // Missing: modulesPath, statePath, server, log + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config = await manager.loadConfig(); + + expect(config.domainBase).toBe("custom"); + expect(config.modulesPath).toBe(DEFAULT_CONFIG.modulesPath); + expect(config.statePath).toBe(DEFAULT_CONFIG.statePath); + expect(config.server.port).toBe(DEFAULT_CONFIG.server.port); + }); + + test("handles invalid YAML gracefully", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write(configPath, "invalid: yaml: content:"); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config = await manager.loadConfig(); + + expect(config).toEqual(DEFAULT_CONFIG); + expect(manager.getConfigPath()).toBeNull(); + }); + + test("handles invalid schema gracefully", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + server: { + port: "not-a-number", // Should be a number + }, + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config = await manager.loadConfig(); + + expect(config).toEqual(DEFAULT_CONFIG); + expect(manager.getConfigPath()).toBeNull(); + }); + + test("caches config after first load", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + domainBase: "cached", + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config1 = await manager.loadConfig(); + expect(config1.domainBase).toBe("cached"); + + // Modify file after first load + await Bun.write( + configPath, + yamlStringify({ + domainBase: "modified", + }), + ); + + // Should still return cached value + const config2 = await manager.loadConfig(); + expect(config2.domainBase).toBe("cached"); + expect(config1).toBe(config2); // Same object reference + }); + + test("reloadConfig forces re-read from file", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + domainBase: "original", + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + await manager.loadConfig(); + + // Modify file + await Bun.write( + configPath, + yamlStringify({ + domainBase: "reloaded", + }), + ); + + const config = await manager.reloadConfig(); + expect(config.domainBase).toBe("reloaded"); + }); + }); + + describe("getConfig", () => { + test("returns DEFAULT_CONFIG if not loaded", () => { + const manager = new ConfigManager({ + configPaths: [join(tempDir, "nonexistent.yml")], + }); + + const config = manager.getConfig(); + + expect(config).toEqual(DEFAULT_CONFIG); + }); + + test("returns loaded config after loadConfig", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + domainBase: "loaded", + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + await manager.loadConfig(); + const config = manager.getConfig(); + + expect(config.domainBase).toBe("loaded"); + }); + }); + + describe("isLoaded", () => { + test("returns false before loadConfig", () => { + const manager = new ConfigManager({ + configPaths: [join(tempDir, "nonexistent.yml")], + }); + + expect(manager.isLoaded()).toBe(false); + }); + + test("returns true after loadConfig", async () => { + const manager = new ConfigManager({ + configPaths: [join(tempDir, "nonexistent.yml")], + }); + + await manager.loadConfig(); + + expect(manager.isLoaded()).toBe(true); + }); + }); + + describe("server config", () => { + test("loads server configuration", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + server: { + port: 9000, + host: "0.0.0.0", + cors: true, + }, + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config = await manager.loadConfig(); + + expect(config.server.port).toBe(9000); + expect(config.server.host).toBe("0.0.0.0"); + expect(config.server.cors).toBe(true); + }); + }); + + describe("log config", () => { + test("loads log configuration", async () => { + const configPath = join(tempDir, "config.yml"); + await Bun.write( + configPath, + yamlStringify({ + log: { + level: "debug", + format: "json", + file: "/var/log/katana.log", + }, + }), + ); + + const manager = new ConfigManager({ + configPaths: [configPath], + }); + + const config = await manager.loadConfig(); + + expect(config.log.level).toBe("debug"); + expect(config.log.format).toBe("json"); + expect(config.log.file).toBe("/var/log/katana.log"); + }); + }); +}); + +describe("convenience functions", () => { + beforeEach(() => { + ConfigManager.resetInstance(); + }); + + afterEach(() => { + ConfigManager.resetInstance(); + }); + + test("getConfigManager returns singleton instance", () => { + const instance1 = getConfigManager(); + const instance2 = getConfigManager(); + + expect(instance1).toBe(instance2); + }); +}); diff --git a/bun/tests/unit/core/dependencies.test.ts b/bun/tests/unit/core/dependencies.test.ts new file mode 100644 index 0000000..404c4e5 --- /dev/null +++ b/bun/tests/unit/core/dependencies.test.ts @@ -0,0 +1,413 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { DependencyResolver } from "../../../src/core/dependencies"; +import type { LoadedModule } from "../../../src/core/module-loader"; +import type { ModuleCategory } from "../../../src/types"; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Create mock LoadedModule objects for testing + */ +function createMockModule( + name: string, + dependsOn: string[] = [], + category: ModuleCategory = "targets", +): LoadedModule { + return { + name, + category, + "depends-on": dependsOn, + sourcePath: `/mock/${name}.yml`, + sourceDir: "/mock", + }; +} + +/** + * Create multiple mock modules from specs + */ +function createMockModules( + specs: Array<{ name: string; dependsOn?: string[]; category?: ModuleCategory }>, +): LoadedModule[] { + return specs.map((spec) => createMockModule(spec.name, spec.dependsOn ?? [], spec.category)); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("DependencyResolver", () => { + describe("buildGraph", () => { + test("builds graph from modules with dependencies", () => { + const modules = createMockModules([ + { name: "a", dependsOn: ["b", "c"] }, + { name: "b", dependsOn: ["c"] }, + { name: "c", dependsOn: [] }, + ]); + + const resolver = new DependencyResolver(modules); + const graph = resolver.buildGraph(); + + expect(graph.nodes.has("a")).toBe(true); + expect(graph.nodes.has("b")).toBe(true); + expect(graph.nodes.has("c")).toBe(true); + expect(graph.edges.get("a")).toEqual(["b", "c"]); + expect(graph.edges.get("b")).toEqual(["c"]); + expect(graph.edges.get("c")).toEqual([]); + }); + + test("handles modules with no dependencies", () => { + const modules = createMockModules([{ name: "standalone1" }, { name: "standalone2" }]); + + const resolver = new DependencyResolver(modules); + const graph = resolver.buildGraph(); + + expect(graph.nodes.size).toBe(2); + expect(graph.edges.get("standalone1")).toEqual([]); + expect(graph.edges.get("standalone2")).toEqual([]); + }); + + test("normalizes module names to lowercase", () => { + const modules = createMockModules([ + { name: "ModuleA", dependsOn: ["ModuleB"] }, + { name: "ModuleB" }, + ]); + + const resolver = new DependencyResolver(modules); + const graph = resolver.buildGraph(); + + expect(graph.nodes.has("modulea")).toBe(true); + expect(graph.nodes.has("moduleb")).toBe(true); + expect(graph.edges.get("modulea")).toEqual(["moduleb"]); + }); + + test("adds dependency nodes even if module not loaded", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["nonexistent"] }]); + + const resolver = new DependencyResolver(modules); + const graph = resolver.buildGraph(); + + expect(graph.nodes.has("app")).toBe(true); + expect(graph.nodes.has("nonexistent")).toBe(true); + }); + }); + + describe("detectCircularDependencies", () => { + test("detects simple cycle A -> B -> A", () => { + const modules = createMockModules([ + { name: "a", dependsOn: ["b"] }, + { name: "b", dependsOn: ["a"] }, + ]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.detectCircularDependencies(); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.type).toBe("circular"); + expect(errors[0]!.details.chain).toBeDefined(); + // The cycle should contain both a and b + const chain = errors[0]!.details.chain!; + expect(chain.includes("a")).toBe(true); + expect(chain.includes("b")).toBe(true); + }); + + test("detects longer cycle A -> B -> C -> A", () => { + const modules = createMockModules([ + { name: "a", dependsOn: ["b"] }, + { name: "b", dependsOn: ["c"] }, + { name: "c", dependsOn: ["a"] }, + ]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.detectCircularDependencies(); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.type).toBe("circular"); + }); + + test("returns empty array when no cycles", () => { + const modules = createMockModules([ + { name: "app", dependsOn: ["db", "cache"] }, + { name: "db", dependsOn: ["base"] }, + { name: "cache", dependsOn: ["base"] }, + { name: "base", dependsOn: [] }, + ]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.detectCircularDependencies(); + + expect(errors).toEqual([]); + }); + + test("detects self-dependency", () => { + const modules = createMockModules([{ name: "self", dependsOn: ["self"] }]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.detectCircularDependencies(); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.type).toBe("circular"); + }); + + test("provides clear error message with cycle path", () => { + const modules = createMockModules([ + { name: "a", dependsOn: ["b"] }, + { name: "b", dependsOn: ["a"] }, + ]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.detectCircularDependencies(); + + expect(errors[0]!.message).toContain("Circular dependency detected"); + expect(errors[0]!.message).toContain("->"); + }); + }); + + describe("validateDependencies", () => { + test("detects missing dependencies", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["nonexistent"] }]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.validateDependencies(); + + expect(errors.length).toBe(1); + expect(errors[0]!.type).toBe("missing"); + expect(errors[0]!.details.missing).toBe("nonexistent"); + expect(errors[0]!.details.module).toBe("app"); + }); + + test("returns empty array when all dependencies exist", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["lib"] }, { name: "lib" }]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.validateDependencies(); + + expect(errors).toEqual([]); + }); + + test("detects multiple missing dependencies", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["missing1", "missing2"] }]); + + const resolver = new DependencyResolver(modules); + const errors = resolver.validateDependencies(); + + expect(errors.length).toBe(2); + }); + }); + + describe("getInstallOrder", () => { + test("returns correct topological order for linear chain", () => { + const modules = createMockModules([ + { name: "app", dependsOn: ["middleware"] }, + { name: "middleware", dependsOn: ["base"] }, + { name: "base", dependsOn: [] }, + ]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("app"); + + expect(result.success).toBe(true); + expect(result.order.length).toBe(3); + + // base should come before middleware, middleware before app + const baseIdx = result.order.indexOf("base"); + const middleIdx = result.order.indexOf("middleware"); + const appIdx = result.order.indexOf("app"); + + expect(baseIdx).toBeLessThan(middleIdx); + expect(middleIdx).toBeLessThan(appIdx); + }); + + test("returns correct topological order for diamond dependency", () => { + const modules = createMockModules([ + { name: "app", dependsOn: ["db", "cache"] }, + { name: "db", dependsOn: ["base"] }, + { name: "cache", dependsOn: ["base"] }, + { name: "base", dependsOn: [] }, + ]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("app"); + + expect(result.success).toBe(true); + expect(result.order.length).toBe(4); + + // base should come before db and cache + const baseIdx = result.order.indexOf("base"); + const dbIdx = result.order.indexOf("db"); + const cacheIdx = result.order.indexOf("cache"); + const appIdx = result.order.indexOf("app"); + + expect(baseIdx).toBeLessThan(dbIdx); + expect(baseIdx).toBeLessThan(cacheIdx); + expect(dbIdx).toBeLessThan(appIdx); + expect(cacheIdx).toBeLessThan(appIdx); + }); + + test("fails with error for circular dependencies", () => { + const modules = createMockModules([ + { name: "a", dependsOn: ["b"] }, + { name: "b", dependsOn: ["a"] }, + ]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("a"); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]!.type).toBe("circular"); + }); + + test("fails with error for missing target module", () => { + const modules = createMockModules([{ name: "existing" }]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("nonexistent"); + + expect(result.success).toBe(false); + expect(result.errors.length).toBe(1); + expect(result.errors[0]!.type).toBe("missing"); + }); + + test("fails with error for missing dependency", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["nonexistent"] }]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("app"); + + expect(result.success).toBe(false); + expect(result.errors[0]!.type).toBe("missing"); + }); + + test("only includes transitive dependencies of target", () => { + const modules = createMockModules([ + { name: "app1", dependsOn: ["shared"] }, + { name: "app2", dependsOn: ["other"] }, + { name: "shared" }, + { name: "other" }, + ]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("app1"); + + expect(result.success).toBe(true); + expect(result.order).toContain("app1"); + expect(result.order).toContain("shared"); + expect(result.order).not.toContain("app2"); + expect(result.order).not.toContain("other"); + }); + + test("returns single item for module with no dependencies", () => { + const modules = createMockModules([{ name: "standalone" }]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("standalone"); + + expect(result.success).toBe(true); + expect(result.order).toEqual(["standalone"]); + }); + + test("handles case-insensitive module names", () => { + const modules = createMockModules([ + { name: "MyApp", dependsOn: ["MyLib"] }, + { name: "MyLib" }, + ]); + + const resolver = new DependencyResolver(modules); + const result = resolver.getInstallOrder("myapp"); + + expect(result.success).toBe(true); + expect(result.order.length).toBe(2); + }); + }); + + describe("getDependents", () => { + test("returns modules that depend on given module", () => { + const modules = createMockModules([ + { name: "app1", dependsOn: ["shared"] }, + { name: "app2", dependsOn: ["shared"] }, + { name: "shared" }, + { name: "standalone" }, + ]); + + const resolver = new DependencyResolver(modules); + const dependents = resolver.getDependents("shared"); + + expect(dependents).toContain("app1"); + expect(dependents).toContain("app2"); + expect(dependents).not.toContain("standalone"); + expect(dependents).not.toContain("shared"); + }); + + test("returns empty array if no modules depend on it", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["lib"] }, { name: "lib" }]); + + const resolver = new DependencyResolver(modules); + const dependents = resolver.getDependents("app"); + + expect(dependents).toEqual([]); + }); + + test("handles case-insensitive lookup", () => { + const modules = createMockModules([ + { name: "App", dependsOn: ["Shared"] }, + { name: "Shared" }, + ]); + + const resolver = new DependencyResolver(modules); + const dependents = resolver.getDependents("SHARED"); + + expect(dependents).toContain("app"); + }); + }); + + describe("hasDependencies", () => { + test("returns true if module has dependencies", () => { + const modules = createMockModules([{ name: "app", dependsOn: ["lib"] }, { name: "lib" }]); + + const resolver = new DependencyResolver(modules); + + expect(resolver.hasDependencies("app")).toBe(true); + }); + + test("returns false if module has no dependencies", () => { + const modules = createMockModules([{ name: "standalone" }]); + + const resolver = new DependencyResolver(modules); + + expect(resolver.hasDependencies("standalone")).toBe(false); + }); + }); + + describe("getDependencies", () => { + test("returns direct dependencies of module", () => { + const modules = createMockModules([ + { name: "app", dependsOn: ["db", "cache"] }, + { name: "db" }, + { name: "cache" }, + ]); + + const resolver = new DependencyResolver(modules); + const deps = resolver.getDependencies("app"); + + expect(deps).toEqual(["db", "cache"]); + }); + + test("returns empty array for module with no dependencies", () => { + const modules = createMockModules([{ name: "standalone" }]); + + const resolver = new DependencyResolver(modules); + + expect(resolver.getDependencies("standalone")).toEqual([]); + }); + + test("returns empty array for unknown module", () => { + const modules = createMockModules([{ name: "existing" }]); + + const resolver = new DependencyResolver(modules); + + expect(resolver.getDependencies("unknown")).toEqual([]); + }); + }); +}); diff --git a/bun/tests/unit/core/executor.test.ts b/bun/tests/unit/core/executor.test.ts new file mode 100644 index 0000000..b9a91cc --- /dev/null +++ b/bun/tests/unit/core/executor.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + allSucceeded, + executeTasks, + getChanges, + getFailures, + TaskExecutor, +} from "../../../src/core/executor"; +import { getMockState, MockState } from "../../../src/core/mock-state"; +import { getPluginRegistry, PluginRegistry } from "../../../src/plugins/registry"; +import type { Task } from "../../../src/types/module"; + +describe("TaskExecutor", () => { + beforeEach(async () => { + MockState.resetInstance(); + PluginRegistry.resetInstance(); + // Load plugins for testing + await getPluginRegistry().loadBuiltinPlugins(); + }); + + afterEach(() => { + MockState.resetInstance(); + PluginRegistry.resetInstance(); + }); + + describe("execute", () => { + test("executes docker task", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ docker: { name: "test", image: "nginx:latest" } }]; + + const results = await executor.execute(tasks, "install"); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(true); + expect(getMockState().containerExists("test")).toBe(true); + }); + + test("executes service task", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + + const results = await executor.execute(tasks, "install"); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(true); + expect(getMockState().serviceRunning("nginx")).toBe(true); + }); + + test("executes lineinfile task", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [ + { lineinfile: { dest: "/etc/hosts", line: "127.0.0.1 test.local", state: "present" } }, + ]; + + const results = await executor.execute(tasks, "install"); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(true); + expect(getMockState().hasLine("/etc/hosts", "127.0.0.1 test.local")).toBe(true); + }); + + test("executes file task", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ file: { path: "/opt/myapp", state: "directory" } }]; + + const results = await executor.execute(tasks, "install"); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(true); + expect(getMockState().fileExists("/opt/myapp")).toBe(true); + }); + + test("executes multiple tasks sequentially", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [ + { service: { name: "docker", state: "running" } }, + { docker: { name: "app", image: "nginx:latest" } }, + { lineinfile: { dest: "/etc/hosts", line: "127.0.0.1 app.local", state: "present" } }, + ]; + + const results = await executor.execute(tasks, "install"); + + expect(results.length).toBe(3); + expect(results.every((r) => r.result.success)).toBe(true); + }); + + test("stops on first failure when stopOnError is true", async () => { + const executor = new TaskExecutor({ mock: true, stopOnError: true }); + const tasks: Task[] = [ + { docker: { name: "existing" } }, // Will fail - no image and doesn't exist + { service: { name: "nginx", state: "running" } }, // Should not execute + ]; + + const results = await executor.execute(tasks, "start"); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(false); + }); + + test("continues on failure when stopOnError is false", async () => { + const executor = new TaskExecutor({ mock: true, stopOnError: false }); + const tasks: Task[] = [ + { docker: { name: "nonexistent" } }, // Will fail - doesn't exist + { service: { name: "nginx", state: "running" } }, + ]; + + const results = await executor.execute(tasks, "start"); + + expect(results.length).toBe(2); + expect(results[0]!.result.success).toBe(false); + expect(results[1]!.result.success).toBe(true); + }); + + test("tracks task duration", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + + const results = await executor.execute(tasks, "install"); + + expect(results[0]!.duration).toBeGreaterThanOrEqual(0); + }); + + test("handles named tasks", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [ + { name: "Start nginx service", service: { name: "nginx", state: "running" } }, + ]; + + const results = await executor.execute(tasks, "install"); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(true); + }); + }); + + describe("events", () => { + test("emits execution:start event", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + let startEvent: [Task[], string] | null = null; + + executor.on("execution:start", (tasks, operation) => { + startEvent = [tasks, operation]; + }); + + await executor.execute(tasks, "install"); + + expect(startEvent).not.toBeNull(); + expect(startEvent![1]).toBe("install"); + }); + + test("emits task:start event", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + const startEvents: Array<{ index: number; total: number }> = []; + + executor.on("task:start", (task, index, total) => { + startEvents.push({ index, total }); + }); + + await executor.execute(tasks, "install"); + + expect(startEvents.length).toBe(1); + expect(startEvents[0]).toEqual({ index: 0, total: 1 }); + }); + + test("emits task:complete event", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + let completeEvent: { success: boolean } | null = null; + + executor.on("task:complete", (task, result, index, total) => { + completeEvent = { success: result.success }; + }); + + await executor.execute(tasks, "install"); + + expect(completeEvent).not.toBeNull(); + expect(completeEvent!.success).toBe(true); + }); + + test("emits execution:complete event", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + let resultsCount = 0; + + executor.on("execution:complete", (results) => { + resultsCount = results.length; + }); + + await executor.execute(tasks, "install"); + + expect(resultsCount).toBe(1); + }); + + test("emits log events through default logger", async () => { + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + const logMessages: string[] = []; + + executor.on("log", (level, message) => { + logMessages.push(`${level}: ${message}`); + }); + + await executor.execute(tasks, "install"); + + expect(logMessages.length).toBeGreaterThan(0); + }); + }); + + describe("operation context", () => { + test("passes install operation to plugins", async () => { + const executor = new TaskExecutor({ mock: true }); + // Create container with image (install creates it) + const tasks: Task[] = [{ docker: { name: "test", image: "nginx:latest" } }]; + + await executor.execute(tasks, "install"); + + expect(getMockState().containerExists("test")).toBe(true); + }); + + test("passes remove operation to plugins", async () => { + // First create a container + getMockState().createContainer("test", "nginx", {}); + + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ docker: { name: "test" } }]; + + await executor.execute(tasks, "remove"); + + expect(getMockState().containerExists("test")).toBe(false); + }); + + test("passes start operation to plugins", async () => { + // First create a stopped container + getMockState().createContainer("test", "nginx", {}); + + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ docker: { name: "test" } }]; + + await executor.execute(tasks, "start"); + + expect(getMockState().containerRunning("test")).toBe(true); + }); + + test("passes stop operation to plugins", async () => { + // First create a running container + const mock = getMockState(); + mock.createContainer("test", "nginx", {}); + mock.startContainer("test"); + + const executor = new TaskExecutor({ mock: true }); + const tasks: Task[] = [{ docker: { name: "test" } }]; + + await executor.execute(tasks, "stop"); + + expect(mock.containerRunning("test")).toBe(false); + }); + }); + + describe("helper functions", () => { + test("executeTasks convenience function", async () => { + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + const results = await executeTasks(tasks, "install", { mock: true }); + + expect(results.length).toBe(1); + expect(results[0]!.result.success).toBe(true); + }); + + test("allSucceeded returns true when all succeed", async () => { + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + const results = await executeTasks(tasks, "install", { mock: true }); + + expect(allSucceeded(results)).toBe(true); + }); + + test("allSucceeded returns false when any fails", async () => { + const tasks: Task[] = [{ docker: { name: "nonexistent" } }]; + const results = await executeTasks(tasks, "start", { mock: true }); + + expect(allSucceeded(results)).toBe(false); + }); + + test("getFailures returns failed tasks", async () => { + const tasks: Task[] = [ + { docker: { name: "nonexistent" } }, + { service: { name: "nginx", state: "running" } }, + ]; + const results = await executeTasks(tasks, "start", { + mock: true, + stopOnError: false, + }); + + const failures = getFailures(results); + expect(failures.length).toBe(1); + }); + + test("getChanges returns changed tasks", async () => { + const tasks: Task[] = [{ service: { name: "nginx", state: "running" } }]; + const results = await executeTasks(tasks, "install", { mock: true }); + + const changes = getChanges(results); + expect(changes.length).toBe(1); + }); + }); +}); diff --git a/bun/tests/unit/core/mock-state.test.ts b/bun/tests/unit/core/mock-state.test.ts new file mode 100644 index 0000000..396dbc8 --- /dev/null +++ b/bun/tests/unit/core/mock-state.test.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getMockState, isMockMode, MockState } from "../../../src/core/mock-state"; + +describe("MockState", () => { + beforeEach(() => { + MockState.resetInstance(); + }); + + afterEach(() => { + MockState.resetInstance(); + }); + + describe("singleton", () => { + test("returns same instance", () => { + const instance1 = MockState.getInstance(); + const instance2 = MockState.getInstance(); + expect(instance1).toBe(instance2); + }); + + test("resetInstance creates new instance", () => { + const instance1 = MockState.getInstance(); + MockState.resetInstance(); + const instance2 = MockState.getInstance(); + expect(instance1).not.toBe(instance2); + }); + + test("getMockState convenience function works", () => { + const instance = getMockState(); + expect(instance).toBeInstanceOf(MockState); + }); + }); + + describe("container management", () => { + test("createContainer creates a new container", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", { "80/tcp": 8080 }); + expect(mock.containerExists("test")).toBe(true); + expect(mock.containerRunning("test")).toBe(false); + }); + + test("startContainer starts a stopped container", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + expect(mock.startContainer("test")).toBe(true); + expect(mock.containerRunning("test")).toBe(true); + }); + + test("startContainer returns false if already running", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + mock.startContainer("test"); + expect(mock.startContainer("test")).toBe(false); + }); + + test("startContainer returns false if container does not exist", () => { + const mock = getMockState(); + expect(mock.startContainer("nonexistent")).toBe(false); + }); + + test("stopContainer stops a running container", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + mock.startContainer("test"); + expect(mock.stopContainer("test")).toBe(true); + expect(mock.containerRunning("test")).toBe(false); + }); + + test("stopContainer returns false if not running", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + expect(mock.stopContainer("test")).toBe(false); + }); + + test("removeContainer removes the container", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + expect(mock.removeContainer("test")).toBe(true); + expect(mock.containerExists("test")).toBe(false); + }); + + test("removeContainer returns false if does not exist", () => { + const mock = getMockState(); + expect(mock.removeContainer("nonexistent")).toBe(false); + }); + + test("getContainer returns container state", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", { "80/tcp": 8080 }); + const container = mock.getContainer("test"); + expect(container).toEqual({ + name: "test", + image: "nginx:latest", + ports: { "80/tcp": 8080 }, + running: false, + }); + }); + }); + + describe("service management", () => { + test("startService starts a service", () => { + const mock = getMockState(); + expect(mock.startService("nginx")).toBe(true); + expect(mock.serviceRunning("nginx")).toBe(true); + }); + + test("startService returns false if already running", () => { + const mock = getMockState(); + mock.startService("nginx"); + expect(mock.startService("nginx")).toBe(false); + }); + + test("stopService stops a service", () => { + const mock = getMockState(); + mock.startService("nginx"); + expect(mock.stopService("nginx")).toBe(true); + expect(mock.serviceRunning("nginx")).toBe(false); + }); + + test("restartService always changes state", () => { + const mock = getMockState(); + expect(mock.restartService("nginx")).toBe(true); + expect(mock.serviceRunning("nginx")).toBe(true); + }); + + test("serviceExists returns true after start", () => { + const mock = getMockState(); + expect(mock.serviceExists("nginx")).toBe(false); + mock.startService("nginx"); + expect(mock.serviceExists("nginx")).toBe(true); + }); + }); + + describe("file management", () => { + test("createDirectory creates a directory", () => { + const mock = getMockState(); + expect(mock.createDirectory("/opt/app")).toBe(true); + expect(mock.fileExists("/opt/app")).toBe(true); + expect(mock.isDirectory("/opt/app")).toBe(true); + }); + + test("createDirectory returns false if exists", () => { + const mock = getMockState(); + mock.createDirectory("/opt/app"); + expect(mock.createDirectory("/opt/app")).toBe(false); + }); + + test("writeFile creates a file", () => { + const mock = getMockState(); + expect(mock.writeFile("/opt/app.txt", "content", "0644")).toBe(true); + expect(mock.fileExists("/opt/app.txt")).toBe(true); + expect(mock.isDirectory("/opt/app.txt")).toBe(false); + }); + + test("writeFile returns false if same content", () => { + const mock = getMockState(); + mock.writeFile("/opt/app.txt", "content", "0644"); + expect(mock.writeFile("/opt/app.txt", "content", "0644")).toBe(false); + }); + + test("removeFile removes file", () => { + const mock = getMockState(); + mock.writeFile("/opt/app.txt", "content"); + expect(mock.removeFile("/opt/app.txt")).toBe(true); + expect(mock.fileExists("/opt/app.txt")).toBe(false); + }); + }); + + describe("line-in-file management", () => { + test("addLine adds a line to file", () => { + const mock = getMockState(); + expect(mock.addLine("/etc/hosts", "127.0.0.1 test")).toBe(true); + expect(mock.hasLine("/etc/hosts", "127.0.0.1 test")).toBe(true); + }); + + test("addLine returns false if line exists", () => { + const mock = getMockState(); + mock.addLine("/etc/hosts", "127.0.0.1 test"); + expect(mock.addLine("/etc/hosts", "127.0.0.1 test")).toBe(false); + }); + + test("removeLine removes a line", () => { + const mock = getMockState(); + mock.addLine("/etc/hosts", "127.0.0.1 test"); + expect(mock.removeLine("/etc/hosts", "127.0.0.1 test")).toBe(true); + expect(mock.hasLine("/etc/hosts", "127.0.0.1 test")).toBe(false); + }); + + test("getLines returns all lines", () => { + const mock = getMockState(); + mock.addLine("/etc/hosts", "127.0.0.1 a"); + mock.addLine("/etc/hosts", "127.0.0.1 b"); + expect(mock.getLines("/etc/hosts")).toContain("127.0.0.1 a"); + expect(mock.getLines("/etc/hosts")).toContain("127.0.0.1 b"); + }); + }); + + describe("reverse proxy management", () => { + test("addReverseProxy adds config", () => { + const mock = getMockState(); + expect(mock.addReverseProxy("test.local", "http://localhost:8080")).toBe(true); + expect(mock.reverseProxyExists("test.local")).toBe(true); + }); + + test("removeReverseProxy removes config", () => { + const mock = getMockState(); + mock.addReverseProxy("test.local", "http://localhost:8080"); + expect(mock.removeReverseProxy("test.local")).toBe(true); + expect(mock.reverseProxyExists("test.local")).toBe(false); + }); + }); + + describe("git repo management", () => { + test("cloneRepo tracks repository", () => { + const mock = getMockState(); + expect(mock.cloneRepo("https://github.com/test/repo", "/opt/repo")).toBe(true); + expect(mock.repoExists("/opt/repo")).toBe(true); + expect(mock.fileExists("/opt/repo")).toBe(true); + }); + + test("cloneRepo returns false if exists", () => { + const mock = getMockState(); + mock.cloneRepo("https://github.com/test/repo", "/opt/repo"); + expect(mock.cloneRepo("https://github.com/test/repo", "/opt/repo")).toBe(false); + }); + }); + + describe("reset", () => { + test("reset clears all state", () => { + const mock = getMockState(); + mock.createContainer("test", "nginx", {}); + mock.startService("nginx"); + mock.createDirectory("/opt/app"); + mock.addLine("/etc/hosts", "test"); + mock.addReverseProxy("test.local", "http://localhost"); + mock.cloneRepo("https://github.com/test", "/opt/repo"); + + mock.reset(); + + expect(mock.containerExists("test")).toBe(false); + expect(mock.serviceExists("nginx")).toBe(false); + expect(mock.fileExists("/opt/app")).toBe(false); + expect(mock.hasLine("/etc/hosts", "test")).toBe(false); + expect(mock.reverseProxyExists("test.local")).toBe(false); + expect(mock.repoExists("/opt/repo")).toBe(false); + }); + }); + + describe("isMockMode", () => { + test("returns false when KATANA_MOCK is not set", () => { + const original = process.env.KATANA_MOCK; + delete process.env.KATANA_MOCK; + expect(isMockMode()).toBe(false); + process.env.KATANA_MOCK = original; + }); + + test("returns true when KATANA_MOCK is 'true'", () => { + const original = process.env.KATANA_MOCK; + process.env.KATANA_MOCK = "true"; + expect(isMockMode()).toBe(true); + process.env.KATANA_MOCK = original; + }); + }); +}); diff --git a/bun/tests/unit/core/module-loader.test.ts b/bun/tests/unit/core/module-loader.test.ts new file mode 100644 index 0000000..75f993a --- /dev/null +++ b/bun/tests/unit/core/module-loader.test.ts @@ -0,0 +1,294 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + formatModuleLoadError, + formatModuleLoaderErrors, + loadAllModules, + loadModule, + ModuleLoader, + validateModuleFile, +} from "../../../src/core/module-loader"; + +const MODULES_DIR = resolve(import.meta.dir, "..", "..", "..", "..", "modules"); + +describe("ModuleLoader", () => { + beforeEach(() => { + ModuleLoader.resetInstance(); + }); + + afterEach(() => { + ModuleLoader.resetInstance(); + }); + + describe("loadAll", () => { + test("loads all modules from modules directory", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadAll(); + + expect(result.modules.length).toBeGreaterThan(0); + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + test("each module has sourcePath and sourceDir", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadAll(); + + for (const module of result.modules) { + expect(module.sourcePath).toBeDefined(); + expect(module.sourcePath).toEndWith(".yml"); + expect(module.sourceDir).toBeDefined(); + } + }); + + test("filters by category", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadAll({ category: "targets" }); + + expect(result.modules.length).toBeGreaterThan(0); + expect(result.modules.every((m) => m.category === "targets")).toBe(true); + }); + + test("filters by tools category", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadAll({ category: "tools" }); + + expect(result.modules.length).toBeGreaterThan(0); + expect(result.modules.every((m) => m.category === "tools")).toBe(true); + }); + }); + + describe("loadFromFile", () => { + test("loads dvwa.yml correctly", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadFromFile(resolve(MODULES_DIR, "targets", "dvwa.yml")); + + expect(result.success).toBe(true); + expect(result.module?.name).toBe("dvwa"); + expect(result.module?.category).toBe("targets"); + expect(result.module?.sourcePath).toContain("dvwa.yml"); + }); + + test("returns error for nonexistent file", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadFromFile("/nonexistent/file.yml"); + + expect(result.success).toBe(false); + expect(result.error?.type).toBe("file_read"); + expect(result.error?.message).toContain("File not found"); + }); + + test("returns error for invalid YAML", async () => { + // Create a temp file with invalid YAML + const tempFile = `/tmp/invalid-yaml-${Date.now()}.yml`; + await Bun.write(tempFile, "name: test\n invalid: indentation"); + + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadFromFile(tempFile); + + expect(result.success).toBe(false); + expect(result.error?.type).toBe("yaml_parse"); + + // Cleanup + (await Bun.file(tempFile).exists()) && (await Bun.write(tempFile, "").then(() => {})); + }); + + test("returns validation error for invalid module schema", async () => { + // Create a temp file with valid YAML but invalid module schema + const tempFile = `/tmp/invalid-module-${Date.now()}.yml`; + await Bun.write( + tempFile, + ` +name: test +category: invalid_category +`, + ); + + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadFromFile(tempFile); + + expect(result.success).toBe(false); + expect(result.error?.type).toBe("validation"); + }); + }); + + describe("loadByName", () => { + test("finds module by name (case-insensitive)", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadByName("DVWA"); + + expect(result.success).toBe(true); + expect(result.module?.name).toBe("dvwa"); + }); + + test("returns error for nonexistent module", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const result = await loader.loadByName("nonexistent-module-xyz"); + + expect(result.success).toBe(false); + expect(result.error?.message).toContain("Module not found"); + }); + }); + + describe("getModuleNames", () => { + test("returns list of module names", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const names = await loader.getModuleNames(); + + expect(names.length).toBeGreaterThan(0); + expect(names).toContain("dvwa"); + }); + }); + + describe("getModulesByCategory", () => { + test("groups modules by category", async () => { + const loader = new ModuleLoader(MODULES_DIR); + const byCategory = await loader.getModulesByCategory(); + + expect(byCategory.has("targets")).toBe(true); + expect(byCategory.has("tools")).toBe(true); + + const targets = byCategory.get("targets"); + expect(targets?.length).toBeGreaterThan(0); + expect(targets?.every((m) => m.category === "targets")).toBe(true); + }); + }); + + describe("caching", () => { + test("uses cache on repeated calls", async () => { + const loader = new ModuleLoader(MODULES_DIR); + + const result1 = await loader.loadAll(); + const result2 = await loader.loadAll(); + + // Both should load the same number of modules + expect(result1.modules.length).toBeGreaterThan(0); + expect(result2.modules.length).toBeGreaterThan(0); + expect(result1.modules.length).toBe(result2.modules.length); + }); + + test("invalidateCache clears cached data", async () => { + const loader = new ModuleLoader(MODULES_DIR); + + await loader.loadAll(); + loader.invalidateCache(); + + // Cache should be cleared, but loadByName should still work (it reloads) + const result = await loader.loadByName("dvwa"); + expect(result.success).toBe(true); + }); + + test("bypasses cache when useCache is false", async () => { + const loader = new ModuleLoader(MODULES_DIR); + + await loader.loadAll(); + const result = await loader.loadAll({ useCache: false }); + + // Should still load modules even without cache + expect(result.modules.length).toBeGreaterThan(0); + }); + }); + + describe("singleton", () => { + test("getInstance returns same instance", () => { + const instance1 = ModuleLoader.getInstance(); + const instance2 = ModuleLoader.getInstance(); + + expect(instance1).toBe(instance2); + }); + + test("resetInstance clears singleton", () => { + const instance1 = ModuleLoader.getInstance(); + ModuleLoader.resetInstance(); + const instance2 = ModuleLoader.getInstance(); + + expect(instance1).not.toBe(instance2); + }); + }); +}); + +describe("convenience functions", () => { + beforeEach(() => { + ModuleLoader.resetInstance(); + }); + + test("loadAllModules works", async () => { + const result = await loadAllModules(); + expect(result.modules.length).toBeGreaterThan(0); + }); + + test("loadModule works", async () => { + const result = await loadModule("dvwa"); + expect(result.success).toBe(true); + expect(result.module?.name).toBe("dvwa"); + }); + + test("validateModuleFile works", async () => { + const result = await validateModuleFile(resolve(MODULES_DIR, "targets", "dvwa.yml")); + expect(result.success).toBe(true); + }); +}); + +describe("error formatting", () => { + test("formatModuleLoadError formats file read error", () => { + const error = { + filePath: "/path/to/file.yml", + type: "file_read" as const, + message: "File not found", + }; + + const formatted = formatModuleLoadError(error); + expect(formatted).toContain("File Read Error"); + expect(formatted).toContain("/path/to/file.yml"); + expect(formatted).toContain("File not found"); + }); + + test("formatModuleLoadError formats YAML error with line number", () => { + const error = { + filePath: "/path/to/file.yml", + type: "yaml_parse" as const, + message: "Unexpected token", + line: 10, + column: 5, + }; + + const formatted = formatModuleLoadError(error); + expect(formatted).toContain("YAML Parse Error"); + expect(formatted).toContain("line 10:5"); + }); + + test("formatModuleLoaderErrors formats multiple errors", () => { + const result = { + modules: [], + errors: [ + { + filePath: "/path/to/file1.yml", + type: "file_read" as const, + message: "Error 1", + }, + { + filePath: "/path/to/file2.yml", + type: "validation" as const, + message: "Error 2", + }, + ], + success: false, + }; + + const formatted = formatModuleLoaderErrors(result); + expect(formatted).toContain("file1.yml"); + expect(formatted).toContain("file2.yml"); + expect(formatted).toContain("Error 1"); + expect(formatted).toContain("Error 2"); + }); + + test("formatModuleLoaderErrors returns empty string for no errors", () => { + const result = { + modules: [], + errors: [], + success: true, + }; + + expect(formatModuleLoaderErrors(result)).toBe(""); + }); +}); diff --git a/bun/tests/unit/core/state-manager.test.ts b/bun/tests/unit/core/state-manager.test.ts new file mode 100644 index 0000000..da0fedd --- /dev/null +++ b/bun/tests/unit/core/state-manager.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { stringify as yamlStringify } from "yaml"; +import { getStateManager, StateManager } from "../../../src/core/state-manager"; +import { ModuleStatus } from "../../../src/types/status"; + +// Use crypto.randomUUID for guaranteed uniqueness (important for parallel test runs) +const createTempDir = () => `/tmp/katana-state-test-${crypto.randomUUID()}`; + +describe("StateManager", () => { + let tempDir: string; + let manager: StateManager; + + beforeEach(async () => { + StateManager.resetInstance(); + tempDir = createTempDir(); + manager = new StateManager({ stateDir: tempDir }); + }); + + afterEach(async () => { + StateManager.resetInstance(); + // Cleanup temp directory + try { + await Bun.$`rm -rf ${tempDir}`.quiet(); + } catch { + // Ignore cleanup errors + } + }); + + describe("singleton", () => { + test("getInstance returns same instance", () => { + StateManager.resetInstance(); + const instance1 = StateManager.getInstance({ stateDir: tempDir }); + const instance2 = StateManager.getInstance({ stateDir: tempDir }); + + expect(instance1).toBe(instance2); + }); + + test("resetInstance clears singleton", () => { + StateManager.resetInstance(); + const instance1 = StateManager.getInstance({ stateDir: tempDir }); + StateManager.resetInstance(); + const instance2 = StateManager.getInstance({ stateDir: tempDir }); + + expect(instance1).not.toBe(instance2); + }); + + test("getStateDir returns configured directory", () => { + expect(manager.getStateDir()).toBe(tempDir); + }); + }); + + describe("ensureStateDir", () => { + test("creates state directory if it doesn't exist", async () => { + const newDir = createTempDir(); + const newManager = new StateManager({ stateDir: newDir }); + + // Install a module (which calls ensureStateDir internally) + await newManager.installModule("test-module"); + + // Check directory exists using test command + const result = await Bun.$`test -d ${newDir}`.nothrow().quiet(); + expect(result.exitCode).toBe(0); + + // Cleanup + await Bun.$`rm -rf ${newDir}`.quiet(); + }); + }); + + describe("installed modules", () => { + test("getInstalledState returns empty state when no file exists", async () => { + const state = await manager.getInstalledState(); + + expect(state.modules).toEqual({}); + }); + + test("isModuleInstalled returns false for uninstalled module", async () => { + const result = await manager.isModuleInstalled("dvwa"); + + expect(result).toBe(false); + }); + + test("installModule adds module to state", async () => { + await manager.installModule("dvwa"); + + const isInstalled = await manager.isModuleInstalled("dvwa"); + expect(isInstalled).toBe(true); + }); + + test("installModule stores installedAt timestamp", async () => { + const before = new Date().toISOString(); + await manager.installModule("dvwa"); + const after = new Date().toISOString(); + + const info = await manager.getModuleInstallInfo("dvwa"); + expect(info).toBeDefined(); + expect(info?.installedAt).toBeDefined(); + // Use optional chaining for safe access, with fallback for comparison + const installedAt = info?.installedAt ?? ""; + expect(installedAt >= before).toBe(true); + expect(installedAt <= after).toBe(true); + }); + + test("installModule stores version if provided", async () => { + await manager.installModule("dvwa", "1.0.0"); + + const info = await manager.getModuleInstallInfo("dvwa"); + expect(info?.version).toBe("1.0.0"); + }); + + test("installModule preserves existing modules", async () => { + await manager.installModule("dvwa"); + await manager.installModule("juice-shop"); + + expect(await manager.isModuleInstalled("dvwa")).toBe(true); + expect(await manager.isModuleInstalled("juice-shop")).toBe(true); + }); + + test("isModuleInstalled is case-insensitive", async () => { + await manager.installModule("DVWA"); + + expect(await manager.isModuleInstalled("dvwa")).toBe(true); + expect(await manager.isModuleInstalled("DVWA")).toBe(true); + expect(await manager.isModuleInstalled("DvWa")).toBe(true); + }); + + test("removeModule removes module from state", async () => { + await manager.installModule("dvwa"); + await manager.removeModule("dvwa"); + + expect(await manager.isModuleInstalled("dvwa")).toBe(false); + }); + + test("removeModule preserves other modules", async () => { + await manager.installModule("dvwa"); + await manager.installModule("juice-shop"); + await manager.removeModule("dvwa"); + + expect(await manager.isModuleInstalled("dvwa")).toBe(false); + expect(await manager.isModuleInstalled("juice-shop")).toBe(true); + }); + + test("removeModule handles non-existent module gracefully", async () => { + // Should not throw + await manager.removeModule("nonexistent"); + expect(await manager.isModuleInstalled("nonexistent")).toBe(false); + }); + + test("getInstalledModuleNames returns list of installed modules", async () => { + await manager.installModule("dvwa"); + await manager.installModule("juice-shop"); + + const names = await manager.getInstalledModuleNames(); + expect(names).toContain("dvwa"); + expect(names).toContain("juice-shop"); + expect(names).toHaveLength(2); + }); + + test("getModuleInstallInfo returns null for uninstalled module", async () => { + const info = await manager.getModuleInstallInfo("nonexistent"); + expect(info).toBeNull(); + }); + }); + + describe("installed.yml persistence", () => { + test("state persists across manager instances", async () => { + await manager.installModule("dvwa"); + + // Create new manager instance with same state dir + StateManager.resetInstance(); + const manager2 = new StateManager({ stateDir: tempDir }); + + expect(await manager2.isModuleInstalled("dvwa")).toBe(true); + }); + + test("handles invalid installed.yml gracefully", async () => { + // Write invalid YAML to installed.yml + await Bun.$`mkdir -p ${tempDir}`.quiet(); + await Bun.write(join(tempDir, "installed.yml"), "invalid: yaml: content"); + + const state = await manager.getInstalledState(); + expect(state.modules).toEqual({}); + }); + + test("handles malformed installed.yml schema gracefully", async () => { + // Write valid YAML but invalid schema + await Bun.$`mkdir -p ${tempDir}`.quiet(); + await Bun.write( + join(tempDir, "installed.yml"), + yamlStringify({ + modules: "not-an-object", + }), + ); + + const state = await manager.getInstalledState(); + expect(state.modules).toEqual({}); + }); + }); + + describe("lock mode", () => { + test("getLockState returns empty state when no lock file exists", async () => { + const state = await manager.getLockState(); + + expect(state.locked).toBe(false); + expect(state.modules).toEqual([]); + }); + + test("isLocked returns false when not locked", async () => { + expect(await manager.isLocked()).toBe(false); + }); + + test("enableLock creates lock file", async () => { + await manager.enableLock(); + + expect(await manager.isLocked()).toBe(true); + }); + + test("enableLock captures installed modules", async () => { + await manager.installModule("dvwa"); + await manager.installModule("juice-shop"); + await manager.enableLock(); + + const locked = await manager.getLockedModules(); + expect(locked).toContain("dvwa"); + expect(locked).toContain("juice-shop"); + }); + + test("enableLock stores metadata", async () => { + await manager.enableLock({ + message: "Production deployment", + lockedBy: "test-user", + }); + + const state = await manager.getLockState(); + expect(state.locked).toBe(true); + expect(state.message).toBe("Production deployment"); + expect(state.lockedBy).toBe("test-user"); + expect(state.lockedAt).toBeDefined(); + }); + + test("enableLock uses USER env var for lockedBy if not specified", async () => { + const originalUser = process.env.USER; + process.env.USER = "env-test-user"; + + await manager.enableLock(); + + const state = await manager.getLockState(); + expect(state.lockedBy).toBe("env-test-user"); + + // Restore + process.env.USER = originalUser; + }); + + test("disableLock removes lock file", async () => { + await manager.enableLock(); + await manager.disableLock(); + + expect(await manager.isLocked()).toBe(false); + }); + + test("disableLock handles missing lock file gracefully", async () => { + // Should not throw + await manager.disableLock(); + expect(await manager.isLocked()).toBe(false); + }); + + test("getLockedModules returns empty array when not locked", async () => { + const modules = await manager.getLockedModules(); + expect(modules).toEqual([]); + }); + }); + + describe("legacy lock file format", () => { + test("reads legacy newline-separated format", async () => { + await Bun.$`mkdir -p ${tempDir}`.quiet(); + await Bun.write(join(tempDir, "katana.lock"), "dvwa\njuice-shop\nbwapp\n"); + + const state = await manager.getLockState(); + expect(state.locked).toBe(true); + expect(state.modules).toEqual(["dvwa", "juice-shop", "bwapp"]); + }); + + test("reads legacy format with extra whitespace", async () => { + await Bun.$`mkdir -p ${tempDir}`.quiet(); + await Bun.write(join(tempDir, "katana.lock"), " dvwa \n\njuice-shop\n \n"); + + const state = await manager.getLockState(); + expect(state.locked).toBe(true); + expect(state.modules).toEqual(["dvwa", "juice-shop"]); + }); + + test("empty legacy file means not locked", async () => { + await Bun.$`mkdir -p ${tempDir}`.quiet(); + await Bun.write(join(tempDir, "katana.lock"), ""); + + const state = await manager.getLockState(); + expect(state.locked).toBe(false); + }); + + test("writes new YAML format when enabling lock", async () => { + await manager.installModule("dvwa"); + await manager.enableLock({ message: "Test" }); + + const content = await Bun.file(join(tempDir, "katana.lock")).text(); + expect(content).toContain("locked: true"); + expect(content).toContain("modules:"); + expect(content).toContain("message: Test"); + }); + }); + + describe("lock file persistence", () => { + test("lock state persists across manager instances", async () => { + await manager.enableLock({ message: "Persistent lock" }); + + StateManager.resetInstance(); + const manager2 = new StateManager({ stateDir: tempDir }); + + expect(await manager2.isLocked()).toBe(true); + const state = await manager2.getLockState(); + expect(state.message).toBe("Persistent lock"); + }); + }); + + describe("module status", () => { + test("returns NOT_INSTALLED for uninstalled module", async () => { + const status = await manager.getModuleStatus("dvwa"); + expect(status).toBe(ModuleStatus.NOT_INSTALLED); + }); + + test("returns INSTALLED for installed module", async () => { + await manager.installModule("dvwa"); + + const status = await manager.getModuleStatus("dvwa"); + expect(status).toBe(ModuleStatus.INSTALLED); + }); + + test("status check is case-insensitive", async () => { + await manager.installModule("dvwa"); + + expect(await manager.getModuleStatus("DVWA")).toBe(ModuleStatus.INSTALLED); + expect(await manager.getModuleStatus("DvWa")).toBe(ModuleStatus.INSTALLED); + }); + }); +}); + +describe("convenience functions", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = createTempDir(); + StateManager.resetInstance(); + }); + + afterEach(async () => { + StateManager.resetInstance(); + try { + await Bun.$`rm -rf ${tempDir}`.quiet(); + } catch { + // Ignore cleanup errors + } + }); + + test("getStateManager returns singleton instance", () => { + const instance1 = getStateManager({ stateDir: tempDir }); + const instance2 = getStateManager({ stateDir: tempDir }); + + expect(instance1).toBe(instance2); + }); +}); diff --git a/bun/tests/unit/core/status.test.ts b/bun/tests/unit/core/status.test.ts new file mode 100644 index 0000000..b4df4c3 --- /dev/null +++ b/bun/tests/unit/core/status.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getMockState, MockState } from "../../../src/core/mock-state"; +import type { LoadedModule } from "../../../src/core/module-loader"; +import { StatusChecker } from "../../../src/core/status"; +import { PluginRegistry } from "../../../src/plugins/registry"; +import { ModuleStatus } from "../../../src/types/status"; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Create a mock module for testing + */ +function createMockModule(overrides: Partial = {}): LoadedModule { + return { + name: "test-module", + category: "targets", + sourcePath: "/mock/test-module.yml", + sourceDir: "/mock", + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("StatusChecker", () => { + beforeEach(async () => { + // Enable mock mode + process.env.KATANA_MOCK = "true"; + + // Reset singletons + MockState.resetInstance(); + PluginRegistry.resetInstance(); + }); + + afterEach(() => { + delete process.env.KATANA_MOCK; + MockState.resetInstance(); + PluginRegistry.resetInstance(); + }); + + describe("checkStatus", () => { + test("returns NOT_INSTALLED when docker container does not exist", async () => { + const module = createMockModule({ + status: { + installed: { exists: { docker: "mycontainer" } }, + running: { started: { docker: "mycontainer" } }, + }, + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.NOT_INSTALLED); + expect(result.installed).toBe(false); + expect(result.running).toBe(false); + }); + + test("returns STOPPED when container exists but not running", async () => { + // Create container but don't start it + const mock = getMockState(); + mock.createContainer("mycontainer", "myimage", {}); + + const module = createMockModule({ + status: { + installed: { exists: { docker: "mycontainer" } }, + running: { started: { docker: "mycontainer" } }, + }, + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.STOPPED); + expect(result.installed).toBe(true); + expect(result.running).toBe(false); + }); + + test("returns RUNNING when container exists and is running", async () => { + const mock = getMockState(); + mock.createContainer("mycontainer", "myimage", {}); + mock.startContainer("mycontainer"); + + const module = createMockModule({ + status: { + installed: { exists: { docker: "mycontainer" } }, + running: { started: { docker: "mycontainer" } }, + }, + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.RUNNING); + expect(result.installed).toBe(true); + expect(result.running).toBe(true); + }); + + test("returns INSTALLED when no running check is defined", async () => { + const mock = getMockState(); + mock.createContainer("mycontainer", "myimage", {}); + + const module = createMockModule({ + status: { + installed: { exists: { docker: "mycontainer" } }, + // No running check defined + }, + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.INSTALLED); + expect(result.installed).toBe(true); + expect(result.running).toBe(false); + }); + + test("returns NOT_INSTALLED when module has no status checks", async () => { + const module = createMockModule({ + // No status section + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.NOT_INSTALLED); + expect(result.installed).toBe(false); + expect(result.running).toBe(false); + }); + + test("checks service status correctly", async () => { + const mock = getMockState(); + mock.startService("nginx"); + + const module = createMockModule({ + status: { + installed: { exists: { service: "nginx" } }, + running: { started: { service: "nginx" } }, + }, + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.RUNNING); + expect(result.installed).toBe(true); + expect(result.running).toBe(true); + }); + + test("checks path/file status correctly", async () => { + const mock = getMockState(); + mock.createDirectory("/opt/myapp"); + + const module = createMockModule({ + status: { + installed: { exists: { path: "/opt/myapp" } }, + }, + }); + + const checker = new StatusChecker(); + const result = await checker.checkStatus(module); + + expect(result.status).toBe(ModuleStatus.INSTALLED); + expect(result.installed).toBe(true); + }); + + test("caches results within TTL", async () => { + const mock = getMockState(); + mock.createContainer("mycontainer", "myimage", {}); + + const module = createMockModule({ + status: { + installed: { exists: { docker: "mycontainer" } }, + }, + }); + + const checker = new StatusChecker({ cacheTTL: 10000 }); + + // First check + const result1 = await checker.checkStatus(module); + expect(result1.installed).toBe(true); + + // Remove the container + mock.removeContainer("mycontainer"); + + // Second check should return cached result + const result2 = await checker.checkStatus(module); + expect(result2.installed).toBe(true); // Still cached as installed + expect(result2.checkedAt).toBe(result1.checkedAt); // Same timestamp + }); + + test("clears cache on clearCache()", async () => { + const mock = getMockState(); + mock.createContainer("mycontainer", "myimage", {}); + + const module = createMockModule({ + status: { + installed: { exists: { docker: "mycontainer" } }, + }, + }); + + const checker = new StatusChecker({ cacheTTL: 10000 }); + + // First check + await checker.checkStatus(module); + + // Remove container + mock.removeContainer("mycontainer"); + + // Clear cache + checker.clearCache(); + + // Should re-check and find container gone + const result = await checker.checkStatus(module); + expect(result.installed).toBe(false); + }); + }); + + describe("checkStatusBatch", () => { + test("checks multiple modules in parallel", async () => { + const mock = getMockState(); + mock.createContainer("container1", "image1", {}); + mock.startContainer("container1"); + // container2 doesn't exist + + const module1 = createMockModule({ + name: "module1", + status: { + installed: { exists: { docker: "container1" } }, + running: { started: { docker: "container1" } }, + }, + }); + + const module2 = createMockModule({ + name: "module2", + status: { + installed: { exists: { docker: "container2" } }, + }, + }); + + const checker = new StatusChecker(); + const results = await checker.checkStatusBatch([module1, module2]); + + expect(results.size).toBe(2); + + const status1 = results.get("module1"); + expect(status1?.status).toBe(ModuleStatus.RUNNING); + expect(status1?.installed).toBe(true); + expect(status1?.running).toBe(true); + + const status2 = results.get("module2"); + expect(status2?.status).toBe(ModuleStatus.NOT_INSTALLED); + expect(status2?.installed).toBe(false); + }); + + test("handles empty module list", async () => { + const checker = new StatusChecker(); + const results = await checker.checkStatusBatch([]); + + expect(results.size).toBe(0); + }); + }); + + describe("formatStatus", () => { + test("formats 'not installed' status", () => { + const result = StatusChecker.formatStatus({ + status: ModuleStatus.NOT_INSTALLED, + installed: false, + running: false, + checkedAt: Date.now(), + }); + + expect(result).toBe("not installed"); + }); + + test("formats 'installed, running' status", () => { + const result = StatusChecker.formatStatus({ + status: ModuleStatus.RUNNING, + installed: true, + running: true, + checkedAt: Date.now(), + }); + + expect(result).toBe("installed, running"); + }); + + test("formats 'installed, stopped' status", () => { + const result = StatusChecker.formatStatus({ + status: ModuleStatus.STOPPED, + installed: true, + running: false, + checkedAt: Date.now(), + }); + + expect(result).toBe("installed, stopped"); + }); + + test("formats 'installed' status (no running check)", () => { + const result = StatusChecker.formatStatus({ + status: ModuleStatus.INSTALLED, + installed: true, + running: false, + checkedAt: Date.now(), + }); + + expect(result).toBe("installed"); + }); + }); +}); diff --git a/bun/tests/unit/plugins/docker.test.ts b/bun/tests/unit/plugins/docker.test.ts new file mode 100644 index 0000000..56a4471 --- /dev/null +++ b/bun/tests/unit/plugins/docker.test.ts @@ -0,0 +1,285 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getMockState, MockState } from "../../../src/core/mock-state"; +import { DockerPlugin } from "../../../src/plugins/docker"; +import type { ExecutionContext, Logger } from "../../../src/types/plugin"; + +describe("DockerPlugin", () => { + let plugin: DockerPlugin; + let logs: string[]; + let mockLogger: Logger; + let baseContext: Omit; + + beforeEach(() => { + MockState.resetInstance(); + plugin = new DockerPlugin(); + logs = []; + mockLogger = { + debug: (msg) => logs.push(`debug: ${msg}`), + info: (msg) => logs.push(`info: ${msg}`), + warn: (msg) => logs.push(`warn: ${msg}`), + error: (msg) => logs.push(`error: ${msg}`), + }; + baseContext = { + mock: true, + dryRun: false, + logger: mockLogger, + }; + }); + + afterEach(() => { + MockState.resetInstance(); + }); + + describe("execute (mock mode)", () => { + describe("install operation", () => { + test("creates and starts container with image", async () => { + const result = await plugin.execute( + { name: "test", image: "nginx:latest", ports: { "80/tcp": 8080 } }, + { ...baseContext, operation: "install" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(true); + expect(getMockState().containerExists("test")).toBe(true); + expect(getMockState().containerRunning("test")).toBe(true); + }); + + test("is idempotent - noop if container already running", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + mock.startContainer("test"); + + const result = await plugin.execute( + { name: "test", image: "nginx:latest" }, + { ...baseContext, operation: "install" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(false); + }); + + test("starts existing stopped container", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + + const result = await plugin.execute( + { name: "test", image: "nginx:latest" }, + { ...baseContext, operation: "install" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(true); + expect(mock.containerRunning("test")).toBe(true); + }); + }); + + describe("start operation", () => { + test("starts existing container", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + + const result = await plugin.execute( + { name: "test" }, + { ...baseContext, operation: "start" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(true); + expect(mock.containerRunning("test")).toBe(true); + }); + + test("fails if container does not exist", async () => { + const result = await plugin.execute( + { name: "nonexistent" }, + { ...baseContext, operation: "start" }, + ); + + expect(result.success).toBe(false); + expect(result.message).toContain("does not exist"); + }); + + test("is idempotent - noop if already running", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + mock.startContainer("test"); + + const result = await plugin.execute( + { name: "test" }, + { ...baseContext, operation: "start" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(false); + }); + }); + + describe("stop operation", () => { + test("stops running container", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + mock.startContainer("test"); + + const result = await plugin.execute( + { name: "test" }, + { ...baseContext, operation: "stop" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(true); + expect(mock.containerRunning("test")).toBe(false); + }); + + test("is idempotent - noop if not running", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + + const result = await plugin.execute( + { name: "test" }, + { ...baseContext, operation: "stop" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(false); + }); + + test("is idempotent - noop if does not exist", async () => { + const result = await plugin.execute( + { name: "nonexistent" }, + { ...baseContext, operation: "stop" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(false); + }); + }); + + describe("remove operation", () => { + test("removes existing container", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + + const result = await plugin.execute( + { name: "test" }, + { ...baseContext, operation: "remove" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(true); + expect(mock.containerExists("test")).toBe(false); + }); + + test("stops and removes running container", async () => { + const mock = getMockState(); + mock.createContainer("test", "nginx:latest", {}); + mock.startContainer("test"); + + const result = await plugin.execute( + { name: "test" }, + { ...baseContext, operation: "remove" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(true); + expect(mock.containerExists("test")).toBe(false); + }); + + test("is idempotent - noop if does not exist", async () => { + const result = await plugin.execute( + { name: "nonexistent" }, + { ...baseContext, operation: "remove" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(false); + }); + }); + }); + + describe("validation", () => { + test("fails with invalid params", async () => { + const result = await plugin.execute( + { invalid: "params" }, + { ...baseContext, operation: "install" }, + ); + + expect(result.success).toBe(false); + expect(result.message).toContain("Invalid docker params"); + }); + + test("fails with missing name", async () => { + const result = await plugin.execute( + { image: "nginx" }, + { ...baseContext, operation: "install" }, + ); + + expect(result.success).toBe(false); + }); + }); + + describe("dry run mode", () => { + test("logs action without executing", async () => { + // Use mock: false so dry run takes precedence + const result = await plugin.execute( + { name: "test", image: "nginx:latest" }, + { mock: false, dryRun: true, logger: mockLogger, operation: "install" }, + ); + + expect(result.success).toBe(true); + expect(result.changed).toBe(false); + expect(logs.some((l) => l.includes("dry-run"))).toBe(true); + }); + }); + + describe("exists", () => { + test("returns true if container exists (mock mode)", async () => { + const originalEnv = process.env.KATANA_MOCK; + process.env.KATANA_MOCK = "true"; + try { + getMockState().createContainer("test", "nginx", {}); + const exists = await plugin.exists({ name: "test" }); + expect(exists).toBe(true); + } finally { + process.env.KATANA_MOCK = originalEnv; + } + }); + + test("returns false if container does not exist (mock mode)", async () => { + const originalEnv = process.env.KATANA_MOCK; + process.env.KATANA_MOCK = "true"; + try { + const exists = await plugin.exists({ name: "nonexistent" }); + expect(exists).toBe(false); + } finally { + process.env.KATANA_MOCK = originalEnv; + } + }); + }); + + describe("started", () => { + test("returns true if container is running (mock mode)", async () => { + const originalEnv = process.env.KATANA_MOCK; + process.env.KATANA_MOCK = "true"; + try { + const mock = getMockState(); + mock.createContainer("test", "nginx", {}); + mock.startContainer("test"); + const started = await plugin.started({ name: "test" }); + expect(started).toBe(true); + } finally { + process.env.KATANA_MOCK = originalEnv; + } + }); + + test("returns false if container is stopped (mock mode)", async () => { + const originalEnv = process.env.KATANA_MOCK; + process.env.KATANA_MOCK = "true"; + try { + getMockState().createContainer("test", "nginx", {}); + const started = await plugin.started({ name: "test" }); + expect(started).toBe(false); + } finally { + process.env.KATANA_MOCK = originalEnv; + } + }); + }); +}); diff --git a/bun/tests/unit/plugins/registry.test.ts b/bun/tests/unit/plugins/registry.test.ts new file mode 100644 index 0000000..51452ec --- /dev/null +++ b/bun/tests/unit/plugins/registry.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { getPluginRegistry, PluginRegistry } from "../../../src/plugins/registry"; +import { BasePlugin, type ExecutionContext, type PluginResult } from "../../../src/types/plugin"; + +// Simple test plugin +class TestPlugin extends BasePlugin { + readonly name = "test"; + + async execute(params: unknown, context: ExecutionContext): Promise { + return this.success("test executed"); + } +} + +describe("PluginRegistry", () => { + beforeEach(() => { + PluginRegistry.resetInstance(); + }); + + afterEach(() => { + PluginRegistry.resetInstance(); + }); + + describe("singleton", () => { + test("returns same instance", () => { + const instance1 = PluginRegistry.getInstance(); + const instance2 = PluginRegistry.getInstance(); + expect(instance1).toBe(instance2); + }); + + test("resetInstance creates new instance", () => { + const instance1 = PluginRegistry.getInstance(); + PluginRegistry.resetInstance(); + const instance2 = PluginRegistry.getInstance(); + expect(instance1).not.toBe(instance2); + }); + + test("getPluginRegistry convenience function works", () => { + const registry = getPluginRegistry(); + expect(registry).toBeInstanceOf(PluginRegistry); + }); + }); + + describe("register", () => { + test("registers a plugin by alias", () => { + const registry = getPluginRegistry(); + const plugin = new TestPlugin(); + + registry.register("test", plugin); + + expect(registry.has("test")).toBe(true); + expect(registry.get("test")).toBe(plugin); + }); + + test("overwrites existing plugin with same alias", () => { + const registry = getPluginRegistry(); + const plugin1 = new TestPlugin(); + const plugin2 = new TestPlugin(); + + registry.register("test", plugin1); + registry.register("test", plugin2); + + expect(registry.get("test")).toBe(plugin2); + }); + }); + + describe("get", () => { + test("returns undefined for unknown alias", () => { + const registry = getPluginRegistry(); + expect(registry.get("unknown")).toBeUndefined(); + }); + }); + + describe("has", () => { + test("returns false for unknown alias", () => { + const registry = getPluginRegistry(); + expect(registry.has("unknown")).toBe(false); + }); + + test("returns true for registered alias", () => { + const registry = getPluginRegistry(); + registry.register("test", new TestPlugin()); + expect(registry.has("test")).toBe(true); + }); + }); + + describe("getAll", () => { + test("returns copy of all plugins", () => { + const registry = getPluginRegistry(); + const plugin = new TestPlugin(); + registry.register("test", plugin); + + const all = registry.getAll(); + + expect(all.size).toBe(1); + expect(all.get("test")).toBe(plugin); + + // Verify it's a copy + all.delete("test"); + expect(registry.has("test")).toBe(true); + }); + }); + + describe("getAliases", () => { + test("returns list of registered aliases", () => { + const registry = getPluginRegistry(); + registry.register("plugin1", new TestPlugin()); + registry.register("plugin2", new TestPlugin()); + + const aliases = registry.getAliases(); + + expect(aliases).toContain("plugin1"); + expect(aliases).toContain("plugin2"); + expect(aliases.length).toBe(2); + }); + }); + + describe("clear", () => { + test("removes all plugins", () => { + const registry = getPluginRegistry(); + registry.register("test", new TestPlugin()); + registry.clear(); + + expect(registry.has("test")).toBe(false); + expect(registry.getAliases().length).toBe(0); + }); + }); + + describe("loadBuiltinPlugins", () => { + test("loads all built-in plugins", async () => { + const registry = getPluginRegistry(); + await registry.loadBuiltinPlugins(); + + // Check all expected plugins are registered + expect(registry.has("docker")).toBe(true); + expect(registry.has("service")).toBe(true); + expect(registry.has("lineinfile")).toBe(true); + expect(registry.has("reverseproxy")).toBe(true); + expect(registry.has("file")).toBe(true); + expect(registry.has("copy")).toBe(true); + expect(registry.has("git")).toBe(true); + expect(registry.has("command")).toBe(true); + expect(registry.has("rm")).toBe(true); + expect(registry.has("get_url")).toBe(true); + expect(registry.has("unarchive")).toBe(true); + expect(registry.has("replace")).toBe(true); + expect(registry.has("desktop")).toBe(true); + }); + }); +}); diff --git a/bun/tests/unit/server/server.test.ts b/bun/tests/unit/server/server.test.ts new file mode 100644 index 0000000..d7efd42 --- /dev/null +++ b/bun/tests/unit/server/server.test.ts @@ -0,0 +1,403 @@ +/** + * Tests for the REST API server + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { StateManager } from "../../../src/core/state-manager"; +import { createServer } from "../../../src/server"; +import { OperationManager } from "../../../src/server/operations"; +import { DEFAULT_CONFIG } from "../../../src/types/config"; + +// Type for API responses in tests +interface TestApiResponse { + success: boolean; + data?: Record; + error?: { + code: string; + message: string; + }; +} + +describe("REST API Server", () => { + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + // Create server with random port + const config = { + ...DEFAULT_CONFIG, + server: { + ...DEFAULT_CONFIG.server, + port: 0, // Let OS assign port + cors: true, + }, + }; + + server = createServer({ config }); + baseUrl = `http://localhost:${server.port}`; + }); + + afterAll(async () => { + server.stop(); + OperationManager.resetInstance(); + + // Clean up any installed modules from tests + const stateManager = StateManager.getInstance(); + for (const mod of await stateManager.getInstalledModuleNames()) { + await stateManager.removeModule(mod); + } + + // Reset the StateManager instance to ensure a clean slate for other tests + StateManager.resetInstance(); + }); + + afterEach(async () => { + // Clean up lock state between tests + const stateManager = StateManager.getInstance(); + if (await stateManager.isLocked()) { + await stateManager.disableLock(); + } + // Note: We don't reset OperationManager between tests because + // tests may still have operations running + }); + + // ========================================================================= + // Health Check + // ========================================================================= + + describe("GET /health", () => { + test("returns health status", async () => { + const res = await fetch(`${baseUrl}/health`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.status).toBe("ok"); + expect(data.data?.version).toBe("0.1.0"); + }); + }); + + // ========================================================================= + // Module Endpoints + // ========================================================================= + + describe("GET /api/modules", () => { + test("returns list of all modules", async () => { + const res = await fetch(`${baseUrl}/api/modules`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(Array.isArray(data.data?.modules)).toBe(true); + expect((data.data?.modules as unknown[]).length).toBeGreaterThan(0); + expect(data.data?.locked).toBe(false); + }); + + test("filters by category", async () => { + const res = await fetch(`${baseUrl}/api/modules?category=targets`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect((data.data?.modules as { category: string }[]).every((m) => m.category === "targets")).toBe(true); + }); + + test("returns 400 for invalid category", async () => { + const res = await fetch(`${baseUrl}/api/modules?category=invalid`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("VALIDATION_ERROR"); + }); + }); + + describe("GET /api/modules/:name", () => { + test("returns module details", async () => { + const res = await fetch(`${baseUrl}/api/modules/dvwa`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.name).toBe("dvwa"); + expect(data.data?.category).toBe("targets"); + expect(data.data?.hasInstallTasks).toBe(true); + }); + + test("returns 404 for unknown module", async () => { + const res = await fetch(`${baseUrl}/api/modules/nonexistent`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(404); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("NOT_FOUND"); + }); + }); + + describe("GET /api/modules/:name/status", () => { + test("returns module status", async () => { + const res = await fetch(`${baseUrl}/api/modules/dvwa/status`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.module).toBe("dvwa"); + expect(typeof data.data?.installed).toBe("boolean"); + expect(typeof data.data?.running).toBe("boolean"); + }); + }); + + // ========================================================================= + // Config Endpoints + // ========================================================================= + + describe("GET /api/config", () => { + test("returns configuration", async () => { + const res = await fetch(`${baseUrl}/api/config`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(typeof data.data?.domainBase).toBe("string"); + expect(typeof data.data?.serverPort).toBe("number"); + expect(typeof data.data?.serverHost).toBe("string"); + }); + }); + + // ========================================================================= + // Lock Endpoints + // ========================================================================= + + describe("Lock API", () => { + test("GET /api/lock returns lock status", async () => { + const res = await fetch(`${baseUrl}/api/lock`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.locked).toBe(false); + }); + + test("POST /api/lock enables lock", async () => { + const res = await fetch(`${baseUrl}/api/lock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Test lock" }), + }); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.locked).toBe(true); + expect(data.data?.message).toBe("Test lock"); + }); + + test("POST /api/lock returns error if already locked", async () => { + // Enable lock first + const stateManager = StateManager.getInstance(); + await stateManager.enableLock({ message: "Already locked" }); + + const res = await fetch(`${baseUrl}/api/lock`, { + method: "POST", + }); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("VALIDATION_ERROR"); + }); + + test("DELETE /api/lock disables lock", async () => { + // Enable lock first + const stateManager = StateManager.getInstance(); + await stateManager.enableLock({ message: "To be unlocked" }); + + const res = await fetch(`${baseUrl}/api/lock`, { + method: "DELETE", + }); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.locked).toBe(false); + }); + }); + + // ========================================================================= + // Module Operations + // ========================================================================= + + describe("Module Operations", () => { + test("POST /api/modules/:name/install returns 403 when locked", async () => { + const stateManager = StateManager.getInstance(); + await stateManager.enableLock({ message: "Locked for test" }); + + const res = await fetch(`${baseUrl}/api/modules/dvwa/install`, { + method: "POST", + }); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(403); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("LOCKED"); + }); + + test("POST /api/modules/:name/install returns 404 for unknown module", async () => { + const res = await fetch(`${baseUrl}/api/modules/nonexistent/install`, { + method: "POST", + }); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(404); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("NOT_FOUND"); + }); + + test("POST /api/modules/:name/install starts operation", async () => { + const res = await fetch(`${baseUrl}/api/modules/dvwa/install`, { + method: "POST", + }); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(202); + expect(data.success).toBe(true); + expect(data.data?.operationId).toBeDefined(); + expect(data.data?.module).toBe("dvwa"); + expect(data.data?.operation).toBe("install"); + expect(["queued", "running"]).toContain(data.data?.status as string); + }); + + test("POST /api/modules/:name/install returns 409 if operation in progress", async () => { + // Start first operation and immediately try second + // Use wordlists module which has only file operations - they should be fast + // but we make requests back-to-back to test concurrency detection + const [res1, res2] = await Promise.all([ + fetch(`${baseUrl}/api/modules/wordlists/install`, { method: "POST" }), + // Small delay to ensure first request is processed first + new Promise((resolve) => + setTimeout(() => fetch(`${baseUrl}/api/modules/wordlists/install`, { method: "POST" }).then(resolve), 5), + ), + ]); + + // First request should succeed + expect(res1.status).toBe(202); + + // Second request should either succeed (if first finished) or be rejected + // Either 202 (if first finished quickly) or 409 (if still in progress) + expect([202, 409]).toContain(res2.status); + + // If it was rejected, verify the error code + if (res2.status === 409) { + const data = (await res2.json()) as TestApiResponse; + expect(data.success).toBe(false); + expect(data.error?.code).toBe("OPERATION_IN_PROGRESS"); + } + }); + + test("start/stop allowed when locked (only for locked modules)", async () => { + const stateManager = StateManager.getInstance(); + + // Clean up any existing installed modules and lock state + if (await stateManager.isLocked()) { + await stateManager.disableLock(); + } + for (const mod of await stateManager.getInstalledModuleNames()) { + await stateManager.removeModule(mod); + } + + // Install modules so they're in the locked list + // Using modules that successfully load (ffuf, trufflehog) + await stateManager.installModule("ffuf"); + await stateManager.installModule("trufflehog"); + + // Enable lock - these modules will be in the locked list + await stateManager.enableLock({ message: "Locked for test" }); + + // Verify lock state is correct + const lockState = await stateManager.getLockState(); + expect(lockState.locked).toBe(true); + expect(lockState.modules).toContain("ffuf"); + expect(lockState.modules).toContain("trufflehog"); + + // Start/stop should work on locked modules + const startRes = await fetch(`${baseUrl}/api/modules/ffuf/start`, { + method: "POST", + }); + expect(startRes.status).toBe(202); + + const stopRes = await fetch(`${baseUrl}/api/modules/trufflehog/stop`, { + method: "POST", + }); + expect(stopRes.status).toBe(202); + + // Start on a non-locked module should return 404 + const nonLockedRes = await fetch(`${baseUrl}/api/modules/zap/start`, { + method: "POST", + }); + expect(nonLockedRes.status).toBe(404); + }); + }); + + // ========================================================================= + // Operation Endpoints + // ========================================================================= + + describe("Operation Endpoints", () => { + test("GET /api/operations/:id returns operation status", async () => { + // Start an operation + const startRes = await fetch(`${baseUrl}/api/modules/zap/install`, { + method: "POST", + }); + const startData = (await startRes.json()) as TestApiResponse; + const operationId = startData.data?.operationId as string; + + // Get operation status + const res = await fetch(`${baseUrl}/api/operations/${operationId}`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data?.operationId).toBe(operationId); + expect(data.data?.module).toBe("zap"); + }); + + test("GET /api/operations/:id returns 404 for unknown operation", async () => { + const res = await fetch(`${baseUrl}/api/operations/nonexistent-id`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(404); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("NOT_FOUND"); + }); + + test("GET /api/operations/:id/stream returns SSE response", async () => { + // Start an operation (using ffuf which loads successfully) + const startRes = await fetch(`${baseUrl}/api/modules/ffuf/install`, { + method: "POST", + }); + const startData = (await startRes.json()) as TestApiResponse; + const operationId = startData.data?.operationId as string; + + // Get SSE stream + const res = await fetch(`${baseUrl}/api/operations/${operationId}/stream`); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/event-stream"); + }); + }); + + // ========================================================================= + // 404 Handling + // ========================================================================= + + describe("404 Handling", () => { + test("returns 404 for unknown routes", async () => { + const res = await fetch(`${baseUrl}/api/unknown`); + const data = (await res.json()) as TestApiResponse; + + expect(res.status).toBe(404); + expect(data.success).toBe(false); + expect(data.error?.code).toBe("NOT_FOUND"); + }); + }); +}); diff --git a/bun/tsconfig.json b/bun/tsconfig.json new file mode 100644 index 0000000..72c45e3 --- /dev/null +++ b/bun/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Path aliases + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "ui"] +} diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..127edd8 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,1034 @@ +# Katana Bun/TypeScript Reimplementation Requirements + +## Executive Summary + +Katana is a specialized package manager for SamuraiWTF (Web Testing Framework), designed to simplify deployment of vulnerable web applications and security testing tools for cybersecurity training environments. This document outlines requirements for a modern reimplementation using Bun/TypeScript that addresses current limitations while maintaining compatibility with existing module definitions. + +## 1. Core Functional Requirements + +### 1.1 Module Management + +**Must Have:** +- Install/remove/start/stop modules (targets, tools, base services) +- Query module status (not installed, installed, stopped, running, blocked, unknown) +- Support for YAML-based module definitions (maintain backward compatibility) +- Automatic dependency resolution and installation +- Idempotent operations (safe to execute multiple times) +- Concurrent operation prevention (no simultaneous operations on same module) + +**Module Categories:** +- `targets` - Vulnerable web applications for penetration testing practice +- `tools` - Security testing tools +- `base` - Infrastructure services (Docker daemon, nginx) +- `management` - Katana system itself + +### 1.2 Command Line Interface + +**Must Have:** +```bash +katana init # Initialize Katana configuration (domain, paths, etc.) +katana install # Install a module +katana remove # Remove a module +katana start # Start a stopped module +katana stop # Stop a running module +katana status # Check module status +katana list [category] # List available modules +katana lock # Lock environment (instructor mode) +katana unlock # Unlock environment +katana update # Update module definitions from repository +``` + +**Nice to Have:** +- `katana validate ` - Validate YAML syntax +- `katana logs ` - View module logs +- Progress indicators for long-running operations +- Colored/formatted output +- Shell completion support + +### 1.3 Web Interface + +**Must Have:** +- Modern responsive UI accessible via configured domain or `http://localhost:8087` +- Real-time operation status updates (no 3-second polling) +- Module listing organized by category +- Action buttons based on current module state +- "Open" button for modules with `href` (disabled when not running) +- Visual feedback during operations (progress, not just "changing") +- Display configured base domain for module access + +**Current Pain Point:** +The Python implementation has timeout issues when installing larger targets from the web interface due to long-running synchronous operations. + +**Solution - Server-Sent Events (SSE):** +- Implement SSE for real-time progress streaming +- Stream installation logs, Docker pull progress, and task execution updates +- Allow cancellation of in-progress operations +- Provide detailed error messages with actionable guidance + +**UI Framework:** +- React with shadcn UI components for modern, accessible component library +- Vite for build tooling (fast, minimal config, works well with Bun) +- TailwindCSS (required by shadcn) for utility-first styling +- shadcn provides copy-paste components built on Radix UI primitives + +### 1.4 Lock Mode (Classroom Management) + +**Use Case:** Instructors pre-configure EC2 instances with specific labs for a class, then lock the environment so students can start/stop labs but cannot install/remove them. + +**Must Have:** +- Lock environment after pre-installing labs: `katana lock` +- When locked, students can ONLY: + - View installed modules + - Start stopped modules + - Stop running modules + - Access running modules (Open button) +- When locked, students CANNOT: + - Install new modules + - Remove installed modules + - Unlock the environment (requires root/instructor access) +- Lock state persists across restarts +- Web UI shows lock indicator (icon, banner, or header badge) +- API endpoints respect lock state (return errors for install/remove) +- Only installed modules visible in module list when locked + +**Lock File Format:** +```yaml +# katana.lock (enhanced format, backward compatible) +locked: true +locked_at: "2025-12-26T10:30:00Z" +locked_by: "instructor" # or "provisioning-script" +message: "Labs for Intro to Web Security - Spring 2025. Contact: instructor@example.com" +modules: + - dvwa + - juice-shop + - dojo-basic +``` + +**Backward Compatibility:** +- Support legacy format (simple newline-separated list) +- Auto-migrate to new format on first lock operation + +**Nice to Have:** +- `katana lock --message "Contact instructor@example.com"` - Custom student message +- `katana lock --code UNLOCK123` - Require unlock code instead of root +- `katana verify` - Verify locked environment integrity +- Bulk install for provisioning: `katana install dvwa juice-shop dojo-basic --silent --wait` +- Class configuration files: `katana install --from-file class-config.yml` +- Time-based auto-unlock (e.g., unlock after semester ends) + +## 2. Module Definition System + +### 2.1 YAML Schema Compatibility + +**Must Have - Maintain backward compatibility with existing modules:** + +```yaml +--- +name: string # Module identifier (used as subdomain) +category: targets|tools|base # Category classification +description: string # Human-readable description +href: string? # URL template for "Open" button (optional) + # Can use {module} and {domain.base} placeholders + # e.g., "https://{module}.{domain.base}:443" + # or legacy format: "https://juice-shop.test:443" +depends-on: string[]? # Module dependencies (optional) +class: string? # Custom provisioner (optional) + +install: # Installation tasks + - name?: string # Optional task description + : # Plugin identifier (docker, service, etc.) + : # Plugin-specific parameters + +remove: [...] # Removal tasks +start: [...] # Start tasks +stop: [...] # Stop tasks + +status: # Status check definitions + running: + started: + docker?: string + service?: string + installed: + exists: + docker?: string + path?: string +``` + +**Module URL Generation:** +- If `href` contains placeholders (`{module}`, `{domain.base}`, `{domain.tls_port}`), they will be replaced with config values +- If `href` is a complete URL (legacy format), it will be used as-is +- If `href` is omitted, Katana will auto-generate: `https://{name}.{domain.base}` (port 443 is standard HTTPS) +- The ReverseProxy plugin will read domain configuration and generate appropriate nginx configs + +### 2.2 Module Discovery + +**Must Have:** +- Scan `modules/` directory recursively for `*.yml` files +- Parse and validate YAML on startup +- Hot reload when module files change (dev mode) +- Graceful error handling for malformed YAML + +### 2.3 Dependency Resolution + +**Must Have:** +- Build dependency graph for all modules +- Detect circular dependencies +- Install/start dependencies before target module +- Fail fast with clear error message on missing dependencies + +**Algorithm:** +- Topological sort for installation order +- Recursive dependency traversal with cycle detection + +## 3. Provisioning System + +### 3.1 Plugin Architecture + +**Design Pattern:** +Similar to the Python implementation but leveraging TypeScript features: + +```typescript +interface IPlugin { + readonly aliases: string[]; // Plugin names (e.g., ['docker']) + + install?(params: PluginParams): Promise; + remove?(params: PluginParams): Promise; + start?(params: PluginParams): Promise; + stop?(params: PluginParams): Promise; + + // Status check methods + exists?(params: PluginParams): Promise; + started?(params: PluginParams): Promise; +} + +abstract class BasePlugin implements IPlugin { + abstract aliases: string[]; + + protected async exec(command: string): Promise { + // Bun.spawn() for command execution + } + + protected validateParams(params: any, schema: Schema): void { + // Parameter validation with Zod + } +} +``` + +### 3.2 Required Plugins + +**Must Have:** + +| Plugin | Purpose | Implementation Notes | +|--------|---------|---------------------| +| **Docker** | Container lifecycle management | Use dockerode (JavaScript Docker client) or Bun.spawn for docker CLI | +| **Service** | systemd service control | Bun.spawn with systemctl commands | +| **ReverseProxy** | Nginx + SSL config | Generate configs using domain.base from config, create SSL certs with openssl | +| **LineInFile** | File content management | Read, modify, write with atomic operations | +| **Copy** | File creation with content | Bun.write() with permissions | +| **File** | Directory creation | fs.mkdir with recursive option | +| **Command** | Shell command execution | Bun.spawn() with shell option | +| **Git** | Repository cloning | Bun.spawn with git CLI (simpler than isomorphic-git) | +| **GetUrl** | HTTP downloads | fetch API with progress tracking | +| **Unarchive** | Archive extraction | tar CLI or JavaScript tar library | +| **Started** | Status: is service running? | Check systemctl/docker status | +| **Exists** | Status: does resource exist? | fs.stat / docker ps checks | + +**Nice to Have:** +- **Compose** - Docker Compose file support (many modern apps use compose) +- **Apt/Dnf** - System package installation +- **Systemd** - Create/manage systemd service files +- **Template** - Jinja2-style templating for config files + +### 3.3 Provisioner Pattern + +**Simplified Design:** +Rather than multiple provisioner classes, use a single `TaskExecutor`: + +```typescript +class TaskExecutor { + private plugins: Map; + + async executeTask(task: Task, emitter?: EventEmitter): Promise { + // 1. Find plugin by task key + // 2. Validate parameters + // 3. Execute appropriate method (install/remove/start/stop) + // 4. Emit progress events for SSE streaming + // 5. Handle errors with context + } + + async executeTasks(tasks: Task[], emitter?: EventEmitter): Promise { + // Execute task list sequentially + // Stream progress via emitter + } +} +``` + +## 4. Installation State Management + +### 4.1 Persistence + +**Must Have:** +- Track installed modules in `installed.yml` (backward compatible) +- Format: `{ [moduleName: string]: version }` +- Atomic file writes (write temp file, then rename) +- File locking to prevent concurrent modifications + +**Future Enhancement:** +- SQLite database for richer state tracking: + - Installation timestamps + - Resource tracking (containers, files, services created) + - Rollback support + - Installation logs + +### 4.2 Status Checking + +**Must Have:** +- Implement status checks defined in module YAML +- Status hierarchy: + 1. Run `running.started` checks → if all pass, status = "running" + 2. Run `installed.exists` checks → if all pass, status = "stopped" or "installed" + 3. Otherwise, status = "not installed" +- Handle checks failing due to permissions/missing tools gracefully + +**Performance:** +- Cache status results with TTL (e.g., 5 seconds) +- Parallel status checks for multiple modules +- Lazy evaluation for list operations + +## 5. Web Server & API + +### 5.1 HTTP Server + +**Technology Choice:** +- Bun.serve() - Native HTTP server (excellent performance, built-in WebSocket) +- Or Hono - Lightweight Express-like framework for Bun (better routing/middleware) + +**Port:** 8087 (maintain backward compatibility) + +### 5.2 API Endpoints + +**REST API:** +``` +GET /api/modules → List all modules with status +GET /api/modules/:category → List modules by category +GET /api/modules/:name → Get module details +POST /api/modules/:name/install → Install module +POST /api/modules/:name/remove → Remove module +POST /api/modules/:name/start → Start module +POST /api/modules/:name/stop → Stop module +GET /api/modules/:name/status → Get module status +GET /api/lock → Get lock status +POST /api/lock → Enable lock mode +DELETE /api/lock → Disable lock mode +``` + +**Server-Sent Events:** +``` +GET /api/modules/:name/operations/:operationId/stream + → Stream operation progress/logs in real-time +``` + +**SSE Event Types:** +```typescript +type SSEEvent = + | { type: 'progress', data: { task: string, current: number, total: number } } + | { type: 'log', data: { level: 'info'|'warn'|'error', message: string } } + | { type: 'status', data: { status: ModuleStatus } } + | { type: 'complete', data: { success: boolean, message?: string } } + | { type: 'error', data: { error: string, details?: string } }; +``` + +### 5.3 Response Formats + +**Success Response:** +```json +{ + "success": true, + "data": { ... }, + "message": "Operation completed successfully" +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": "ModuleNotFoundError", + "message": "Module 'juice-shop' not found", + "details": "Available modules: ..." +} +``` + +### 5.4 Operation Tracking + +**Challenge:** Long-running operations in web context + +**Solution:** +```typescript +// 1. Generate operation ID +const operationId = crypto.randomUUID(); + +// 2. Start operation in background (Bun Worker or async) +operations.set(operationId, { + moduleId: 'juice-shop', + type: 'install', + status: 'running', + emitter: new EventEmitter() +}); + +// 3. Return operation ID immediately +return { operationId, streamUrl: `/api/operations/${operationId}/stream` }; + +// 4. Client connects to SSE stream +// 5. Stream progress events until completion +``` + +## 6. Error Handling & Logging + +### 6.1 Error Types + +**Define custom errors:** +```typescript +class ModuleNotFoundError extends Error {} +class DependencyError extends Error {} +class PluginError extends Error {} +class PermissionError extends Error {} +class TimeoutError extends Error {} +class LockError extends Error {} // Operation blocked by lock mode +``` + +### 6.2 Logging + +**Requirements:** +- Structured logging (JSON format for parsing) +- Log levels: DEBUG, INFO, WARN, ERROR +- Separate log files for: + - Application logs: `/var/log/katana/katana.log` + - Operation logs: `/var/log/katana/operations/.log` +- Log rotation (max size, max age) + +**Libraries:** +- pino - High-performance JSON logger (works with Bun) +- winston - More features but slower + +### 6.3 Recovery & Rollback + +**Nice to Have:** +- Track resources created during installation (containers, files, services) +- On failure, attempt to rollback changes +- Idempotent operations allow re-running install to fix incomplete states +- `katana repair ` command to fix broken installations + +## 7. Security Considerations + +### 7.1 Execution Context + +**Important:** +- Katana requires root/sudo privileges (Docker, systemd, /etc/hosts, nginx) +- Designed for isolated training environments, NOT production +- No authentication on web interface (assumes trusted network) + +**Recommendations for Bun version:** +- Document privilege requirements clearly +- Add `--unsafe-perm` flag check +- Provide Docker-in-Docker option (run Katana in privileged container) +- Optional: Add basic auth for web UI (environment variable controlled) + +### 7.2 Input Validation + +**Must Have:** +- Validate all YAML inputs against schema (use Zod or JSON Schema) +- Sanitize shell command inputs (prevent command injection) +- Validate file paths (prevent directory traversal) +- Whitelist module names (alphanumeric + hyphen only) + +**Libraries:** +- Zod - TypeScript-first schema validation +- shell-escape - Escape shell command arguments + +### 7.3 SSL Certificates + +**Current Approach:** +- Self-signed certificates generated per module +- Stored in `/etc/samurai.d/certs/` +- Users must accept browser warnings + +**Enhancement Consideration:** +- Generate single CA certificate on first run +- Sign all module certificates with CA +- Provide CA cert for user import (eliminates warnings) + +## 8. Platform Support & Portability + +### 8.1 Platform Priority + +**Primary Target:** Linux (Ubuntu 24.04 LTS, Debian 12+) + +**Must Have - Linux:** +- Docker support (dockerd via systemd or socket) +- systemd service management +- nginx reverse proxy +- /etc/hosts management +- OpenSSL for certificate generation + +**Nice to Have - Other Platforms:** +- macOS support (Lima VM for Docker, /etc/hosts, nginx via Homebrew) +- WSL2 support (Windows Subsystem for Linux) + +**Explicitly Not Supported:** +- Native Windows (too many dependencies on Unix tooling) + +### 8.2 Dependency Detection + +**Runtime Checks:** +On startup, verify: +```typescript +const REQUIRED_DEPENDENCIES = [ + { name: 'docker', check: 'docker --version', required: true }, + { name: 'systemd', check: 'systemctl --version', required: true }, + { name: 'nginx', check: 'nginx -v', required: true }, + { name: 'openssl', check: 'openssl version', required: true }, + { name: 'git', check: 'git --version', required: false }, +]; +``` + +**Graceful Degradation:** +- If Docker not available: disable Docker-based modules +- If systemd not available: fall back to direct process management +- Clear error messages: "Module requires Docker which is not available" + +### 8.3 Installation Methods + +**Primary Method:** +- Compiled binary executable (download and run, no dependencies) +- GitHub Releases provide pre-compiled binaries for Linux x64/ARM64 + +**Development:** +- Clone repository and run with `bun run src/cli.ts` + +**Future Enhancements:** +- DEB/RPM packages for system integration +- Docker image (Katana running in container managing host Docker) +- Homebrew formula (macOS) +- NPM package (if broader distribution desired) + +### 8.4 Configuration + +**Config File:** `/etc/katana/config.yml` (or `~/.config/katana/config.yml`) + +**Initialization:** Run `katana init` to interactively configure Katana: +- Base domain for module access (e.g., `mydomain.internal`, `wtf`, `local`) +- Installation paths +- Server port +- Generates initial config file + +**Configuration Structure:** + +```yaml +# Katana Configuration + +domain: + base: wtf # Base domain for modules (e.g., dvwa.wtf, juice-shop.wtf) + tls_port: 443 # Port for TLS/HTTPS access via nginx reverse proxy + ui_hostname: katana # Hostname for Katana UI (e.g., katana.wtf) + +server: + host: 0.0.0.0 + port: 8087 # Direct HTTP port for Katana API/UI + +paths: + modules: /opt/katana/modules + installed: /opt/katana/installed.yml + lock: /opt/katana/katana.lock + logs: /var/log/katana + certs: /etc/samurai.d/certs + nginx_conf: /etc/nginx/conf.d + +logging: + level: info + format: json + +features: + auth_enabled: false + auth_user: admin + auth_password: changeme +``` + +**Domain Configuration Details:** + +When a module is installed (e.g., `dvwa`), Katana will: +1. Add `/etc/hosts` entry: `127.0.0.1 dvwa.{base_domain}` +2. Generate nginx reverse proxy config for `dvwa.{base_domain}` on port 443 +3. Create self-signed SSL certificate for the module +4. Module becomes accessible at `https://dvwa.{base_domain}` (standard HTTPS port) + +**Examples:** +- `base: wtf` → modules accessible at `https://dvwa.wtf`, `https://juice-shop.wtf` +- `base: mydomain.internal` → modules at `https://dvwa.mydomain.internal` +- `base: local` → modules at `https://dvwa.local` + +The Katana web UI itself will be accessible at: +- Direct: `http://localhost:{server.port}` (e.g., `http://localhost:8087`) +- Via reverse proxy: `https://{ui_hostname}.{base_domain}` (e.g., `https://katana.wtf`) + +**Backward Compatibility:** +- Default configuration uses `base: wtf` to match existing behavior +- Existing modules work without modification +- Users can customize domain during `katana init` or by editing config file + +## 9. Performance Requirements + +### 9.1 Responsiveness + +**Must Have:** +- Web UI initial load: < 1 second +- Module list API: < 100ms +- Status check (single module): < 500ms +- Status check (all modules): < 2 seconds + +### 9.2 Concurrent Operations + +**Must Have:** +- Support multiple concurrent operations on different modules +- Block concurrent operations on same module +- Maximum concurrent installations: configurable (default: 3) + +**Implementation:** +```typescript +class OperationQueue { + private running = new Map>(); + private maxConcurrent = 3; + + async run(moduleId: string, operation: () => Promise): Promise { + // Check if operation already running for module + if (this.running.has(moduleId)) { + throw new Error('Operation already in progress'); + } + + // Wait if too many operations running + while (this.running.size >= this.maxConcurrent) { + await Promise.race(this.running.values()); + } + + // Execute operation + const promise = operation().finally(() => this.running.delete(moduleId)); + this.running.set(moduleId, promise); + return promise; + } +} +``` + +## 10. Testing Requirements + +### 10.1 Unit Tests + +**Must Have:** +- Plugin logic (Docker, Service, ReverseProxy, etc.) +- YAML parsing and validation +- Dependency resolution algorithm +- Status checking logic +- Error handling + +**Framework:** Bun built-in test runner + +### 10.2 Integration Tests + +**Must Have:** +- Module installation/removal/start/stop workflows +- API endpoint behavior +- SSE streaming +- Lock mode functionality + +**Approach:** +- Test against real Docker daemon (require Docker in CI) +- Mock systemd interactions (or use user systemd services) +- Fixture modules for testing + +### 10.3 End-to-End Tests + +**Nice to Have:** +- Playwright tests for web UI +- Test actual module installations (juice-shop, etc.) +- Test reverse proxy and SSL certificate generation + +**Current Approach:** +The Python version uses bash scripts in `tests/` that: +1. Install modules +2. Verify HTTP endpoints are accessible +3. Check service status +4. Clean up modules + +This approach should be maintained with improvements: +- Port tests to TypeScript (better error handling) +- Add retry logic with exponential backoff +- Parallel test execution where possible + +## 11. Documentation Requirements + +### 11.1 User Documentation + +**Must Have:** +- README with installation instructions +- Usage guide (CLI commands, web UI) +- Module author guide (creating new modules) +- Troubleshooting guide +- Architecture overview + +### 11.2 Developer Documentation + +**Must Have:** +- API documentation (OpenAPI/Swagger spec) +- Plugin development guide +- Architecture decision records (ADRs) +- Contributing guide + +### 11.3 Inline Documentation + +**Must Have:** +- TSDoc comments for all public APIs +- README in each major directory +- Schema documentation for YAML format + +## 12. Migration & Backward Compatibility + +### 12.1 Module Compatibility + +**Must Have:** +- Support existing YAML module definitions without modification +- Parse and execute all current plugin types +- Maintain port assignments and reverse proxy behavior + +### 12.2 State Migration + +**Must Have:** +- Read existing `installed.yml` file +- Honor existing `katana.lock` file +- Import module installation state on first run + +### 12.3 Deprecation Path + +**Future:** +- Run Python and Bun versions side-by-side during transition +- Provide migration tool: `katana-migrate export/import` +- Document breaking changes and migration steps + +## 13. Development Workflow + +### 13.1 Project Structure + +``` +katana/ +├── src/ +│ ├── cli/ # CLI entry point and commands +│ ├── server/ # Web server and API routes +│ ├── core/ # Core engine (module loading, orchestration) +│ ├── plugins/ # Plugin implementations +│ ├── provisioners/ # Provisioning logic +│ ├── utils/ # Shared utilities +│ └── types/ # TypeScript type definitions +├── modules/ # Module YAML definitions +├── tests/ +│ ├── unit/ +│ ├── integration/ +│ └── e2e/ +├── docs/ +├── scripts/ # Build and maintenance scripts +├── package.json +├── tsconfig.json +└── bunfig.toml +``` + +### 13.2 Development Tools + +**Must Have:** +- TypeScript with strict mode +- Biome or ESLint + Prettier for linting/formatting +- Bun test runner for unit/integration tests +- Bun build for compilation + +### 13.3 CI/CD + +**Must Have:** +- GitHub Actions workflows +- Run tests on push/PR +- Test on Ubuntu 24.04 LTS (primary target) +- Code coverage reporting + +**Nice to Have:** +- Test on multiple Linux distros (Debian 12+, Fedora) +- Automated release builds +- Publish to NPM on tag + +## 14. Non-Functional Requirements + +### 14.1 Code Quality + +**Must Have:** +- TypeScript strict mode (no implicit any) +- Minimum 70% code coverage +- No critical security vulnerabilities (npm audit / Snyk) +- Documented public APIs + +### 14.2 Maintainability + +**Principles:** +- Separation of concerns (CLI / Server / Core / Plugins) +- Dependency injection for testability +- Immutable data structures where possible +- Functional programming style for business logic + +### 14.3 Observability + +**Must Have:** +- Structured logging (pino or similar) +- Operation tracing (correlate logs by operationId) +- Health check endpoint: `GET /health` + +**Nice to Have:** +- Metrics endpoint (Prometheus format) +- OpenTelemetry tracing support + +## 15. EC2 Deployment Considerations + +### 15.1 Use Case: Pre-Configured Student Instances + +**Scenario:** Instructor provisions one EC2 instance per student, pre-installs class-specific labs, locks the environment. + +**Workflow:** +```bash +# EC2 provisioning script (user-data or Terraform) +#!/bin/bash + +# Install Katana +curl -L https://github.com/SamuraiWTF/katana/releases/latest/download/katana \ + -o /usr/local/bin/katana +chmod +x /usr/local/bin/katana + +# Configure for student +katana init --non-interactive \ + --domain-base="student${STUDENT_ID}.training.example.com" \ + --tls-port=443 \ + --server-port=8087 + +# Install class labs +katana install dvwa juice-shop dojo-basic --silent --wait + +# Optional: Start certain labs +katana start dvwa juice-shop + +# Lock environment +katana lock --message "Intro to Web Security Labs. Contact: instructor@example.com" +``` + +### 15.2 Headless Environment Adaptations + +**Desktop Integration:** +- Detect headless environment (no $DISPLAY, no GNOME) +- Automatically skip `DesktopIntegration` plugin tasks +- Log warning instead of failing +- Feature flag: `features.desktop_integration: false` in config + +**Network Configuration:** +- Use real DNS or EC2 public DNS instead of 127.0.0.1 +- Support Route53 or external DNS integration +- Generate nginx configs with public hostnames +- SSL certificates work with actual domains + +### 15.3 AWS-Specific Features + +**Nice to Have:** +- Automatic Route53 DNS record creation during `katana init` +- CloudWatch log integration +- S3 backup for installed.yml and katana.lock +- EC2 metadata service integration (auto-detect public hostname) +- Support for Application Load Balancer routing (instead of direct EC2 access) + +### 15.4 Security Group Requirements + +**Required Ports:** +- 443 (HTTPS) - Lab access via nginx reverse proxy +- 8087 (HTTP) - Katana web UI (optional: can restrict to VPN) +- 22 (SSH) - Optional, for instructor access + +**Recommended:** +- Restrict Katana UI (8087) to VPN or instructor IP +- Labs (443) accessible to students +- Use security group rules to limit access scope + +### 15.5 Resource Sizing + +**Minimum EC2 Instance Requirements:** +- Instance Type: t3.medium (2 vCPU, 4GB RAM) +- Storage: 20GB EBS (gp3) +- OS: Ubuntu 24.04 LTS + +**Scaling by Lab Count:** +- 1-3 labs: t3.medium (2 vCPU, 4GB RAM) +- 4-6 labs: t3.large (2 vCPU, 8GB RAM) +- 7+ labs: t3.xlarge (4 vCPU, 16GB RAM) + +**Cost Optimization:** +- Use Spot Instances for temporary training sessions +- Scheduled shutdown for nights/weekends +- Terminate instances after class ends + +### 15.6 Multi-Region Deployment + +**Use Case:** Distribute students across regions for performance + +**Terraform/CloudFormation Pattern:** +```hcl +# Deploy identical student instances in multiple regions +module "student_instances" { + for_each = toset(["us-east-1", "us-west-2", "eu-west-1"]) + + source = "./modules/katana-student" + region = each.key + student_ids = var.students_in_region[each.key] +} +``` + +**DNS Strategy:** +- Use latency-based routing in Route53 +- Or: Assign students to nearest region +- Student URL: `https://student123.region.training.example.com` + +## 16. Timeline-Free Phased Implementation + +### Phase 1: Core Foundation +- CLI skeleton with command routing +- YAML module loader and validator +- Basic plugin system (Docker, Command, File, Copy) +- Simple provisioning engine +- Unit tests for core logic + +### Phase 2: Web Interface & SSE +- Bun.serve HTTP server with routing +- REST API endpoints +- SSE implementation for operation streaming +- Basic HTML/CSS UI (no framework yet) +- Operation queue and concurrency control + +### Phase 3: Complete Plugin Set +- Implement all required plugins (Service, ReverseProxy, etc.) +- Status checking system +- Dependency resolution +- Integration tests + +### Phase 4: Feature Parity +- Lock mode +- State persistence (installed.yml) +- Configuration file support +- Error handling and recovery +- E2E tests with real modules + +### Phase 5: Modern UI +- React/Preact frontend with Vite +- Real-time updates via SSE +- Progress indicators and log streaming +- Responsive design + +### Phase 6: Polish & Production Ready +- Documentation (user + developer) +- Installation packages (DEB, binary) +- Migration tools +- Performance optimization +- Security hardening + +## 16. Success Criteria + +**The Bun/TypeScript implementation will be considered successful when:** + +1. **Feature Parity:** All existing modules can be installed/managed without modification +2. **Performance:** Web operations complete without timeouts, even for large targets +3. **User Experience:** Real-time progress feedback eliminates confusion during installations +4. **Reliability:** Idempotent operations and proper error handling prevent broken states +5. **Maintainability:** TypeScript codebase is easier to understand and extend than Python version +6. **Compatibility:** Existing instructors can switch with minimal disruption +7. **Documentation:** New users can get started without reading Python code + +## 17. Open Questions & Decisions Needed + +### 17.1 UI Framework Choice + +**Decision:** React + Vite + shadcn UI + +**Rationale:** +- shadcn UI provides accessible, customizable components built on Radix UI +- Copy-paste component model (no package bloat, full control) +- TailwindCSS integration for consistent styling +- Excellent TypeScript support +- Active community and comprehensive documentation + +### 17.2 HTTP Framework +**Options:** +1. **Bun.serve** - Native, minimal, requires manual routing +2. **Hono** - Express-like, designed for edge runtimes, excellent TypeScript support +3. **Elysia** - Bun-first framework, great DX, includes WebSocket support + +**Recommendation:** Hono (best balance of simplicity and features) + +### 17.3 Docker Integration +**Options:** +1. **dockerode** - Official Docker client for Node.js/Bun +2. **Bun.spawn('docker', [...])** - Shell out to Docker CLI + +**Recommendation:** Start with Bun.spawn (simpler), migrate to dockerode if needed + +### 17.4 State Management + +**Decision:** YAML files + +**Rationale:** +- Maintains backward compatibility with Python implementation +- Simple, human-readable format +- No additional database dependencies +- Sufficient for current use cases +- Can migrate to SQLite in future if needed + +### 17.5 Distribution Method + +**Decision:** Compiled binary executable + +**Rationale:** +- Bun can compile to single executable binary (no runtime dependencies) +- GitHub Actions workflow will compile on tag push for releases +- Local development: run directly with Bun +- Simplest distribution method (just download and run) +- No NPM/system package complexity for initial release + +**Implementation:** +- Development: `bun run src/cli.ts` +- Production: `bun build --compile --outfile katana src/cli.ts` +- CI/CD: GitHub Actions compiles binary on git tag, attaches to release + +## 18. Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Bun runtime bugs/incompatibilities | High | Test extensively; fall back to Node.js if needed | +| Docker API changes breaking plugin | Medium | Version lock dockerode; integration tests | +| Timeout issue persists with SSE | High | Implement cancellation; use WebWorkers for CPU-intensive ops | +| Module definitions need updates | Low | Maintain backward compatibility; provide migration path | +| Performance worse than Python | Medium | Profile early; optimize hot paths; consider native modules | +| Breaking changes during development | Medium | Semantic versioning; clear deprecation notices | + +## 19. References + +- **Python Implementation:** Current codebase in repository root +- **Module Definitions:** `modules/**/*.yml` files +- **Bun Documentation:** https://bun.sh/docs +- **TypeScript Handbook:** https://www.typescriptlang.org/docs/ +- **Hono Framework:** https://hono.dev/ +- **Server-Sent Events:** https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events +- **Docker API:** https://docs.docker.com/engine/api/ +- **nginx Configuration:** https://nginx.org/en/docs/ + +--- + +## Next Steps + +1. **Review & Feedback** - Stakeholder review of this requirements document +2. **Technical Spike** - Prototype SSE implementation with real Docker operations +3. **Architecture Design** - Create detailed component diagrams and interfaces +4. **Development Kickoff** - Begin Phase 1 implementation diff --git a/modules/management/katana.yml b/modules/management/katana.yml index fb7ebbc..61ebe04 100644 --- a/modules/management/katana.yml +++ b/modules/management/katana.yml @@ -21,7 +21,7 @@ install: [Install] WantedBy=multi-user.target - mode: 0744 + mode: "0744" # - name: Create service socket for samurai-katana # copy: @@ -33,7 +33,7 @@ install: # # [Install] # WantedBy=sockets.target -# mode: 0744 +# mode: "0744" - name: Setup hosts file entries (wtf) lineinfile: diff --git a/modules/targets/amoksecurity.yml b/modules/targets/amoksecurity.yml index c1c5107..b4924de 100644 --- a/modules/targets/amoksecurity.yml +++ b/modules/targets/amoksecurity.yml @@ -27,7 +27,7 @@ install: root /var/www/amoksecurity; } } - mode: 0744 + mode: "0744" - service: name: nginx diff --git a/modules/targets/juice-shop.yml b/modules/targets/juice-shop.yml index d21411b..3861223 100644 --- a/modules/targets/juice-shop.yml +++ b/modules/targets/juice-shop.yml @@ -78,7 +78,6 @@ status: running: started: docker: juice-shop - -installed: - exists: - docker: juice-shop + installed: + exists: + docker: juice-shop diff --git a/modules/targets/k8s-labs.yml b/modules/targets/k8s-labs.yml index 18f5b39..c49b8a8 100644 --- a/modules/targets/k8s-labs.yml +++ b/modules/targets/k8s-labs.yml @@ -69,7 +69,7 @@ proxy_pass http://{{CLUSTER_IP}}:31380; } } - mode: 0644 + mode: "0644" - name: Set up api nginx reverse-proxy config copy: @@ -82,7 +82,7 @@ proxy_pass http://{{CLUSTER_IP}}:31337; } } - mode: 0644 + mode: "0644" - name: Set cluster IP in nginx configs command: diff --git a/modules/targets/mutillidae.yml b/modules/targets/mutillidae.yml index 0ec9302..0f24fd6 100644 --- a/modules/targets/mutillidae.yml +++ b/modules/targets/mutillidae.yml @@ -75,7 +75,7 @@ install: ExecStop=/usr/bin/docker compose stop [Install] WantedBy=multi-user.target - mode: 0744 + mode: "0744" - service: diff --git a/modules/targets/samurai-dojo.yml b/modules/targets/samurai-dojo.yml index ae3e732..4923462 100644 --- a/modules/targets/samurai-dojo.yml +++ b/modules/targets/samurai-dojo.yml @@ -39,7 +39,7 @@ install: $dbpass = 'samurai'; $dbname = 'samurai_dojo_basic'; ?> - mode: 0744 + mode: "0744" - name: Remove .htaccess if present file: @@ -60,7 +60,7 @@ install: id=$(sudo docker ps -aqf "name=scavengerdb") sudo docker cp ./scavenger.sql $id:/ sudo docker exec $id /bin/sh -c 'mysql -u root -psamurai samurai_dojo_scavenger