diff --git a/.cursor/rules/SHADES_COMPONENTS.mdc b/.cursor/rules/SHADES_COMPONENTS.mdc index 81a9d34..69be023 100644 --- a/.cursor/rules/SHADES_COMPONENTS.mdc +++ b/.cursor/rules/SHADES_COMPONENTS.mdc @@ -374,7 +374,6 @@ Export pages from an index file: // frontend/src/pages/index.ts export * from './dashboard.js' export * from './login.js' -export * from './hello-world.js' ``` ## Application Entry Point diff --git a/.cursor/rules/TESTING_GUIDELINES.mdc b/.cursor/rules/TESTING_GUIDELINES.mdc index 1ffb894..897049a 100644 --- a/.cursor/rules/TESTING_GUIDELINES.mdc +++ b/.cursor/rules/TESTING_GUIDELINES.mdc @@ -191,6 +191,56 @@ describe('ObservableValue', () => { }) ``` +## Backend Service Testing with Test Helpers + +### Using `withTestInjector` + +For testing backend services and REST actions, use the shared test helpers: + +```typescript +import { describe, it, expect, vi } from 'vitest' +import { getRepository } from '@furystack/repository' +import { ServiceDefinition, ServiceStatus } from 'common' + +import { withTestInjector, createMockActionContext } from '../test-helpers.js' + +describe('MyAction', () => { + it('should return data for valid request', () => + withTestInjector(async ({ injector, elevated }) => { + // Seed test data + await getRepository(elevated).getDataSetFor(ServiceDefinition, 'id').add(elevated, { + id: 'svc-1', + stackName: 'test-stack', + displayName: 'Test Service', + // ... other required fields + }) + + // Mock external services via setExplicitInstance + const mockService = { someMethod: vi.fn().mockResolvedValue('result') } + injector.setExplicitInstance(mockService as unknown as MyService, MyService) + + // Create and execute action + const ctx = createMockActionContext({ + injector: elevated, + urlParams: { id: 'svc-1' }, + }) + const result = await myAction(ctx) + + // Assert + expect(result.chunk.status).toBe(200) + }), + ) +}) +``` + +### Key patterns + +- `withTestInjector` provides an `injector` + `elevated` (system-level) context with all InMemoryStores pre-configured +- `createMockActionContext` creates a typed request context for REST action testing +- Use `getRepository(elevated).getDataSetFor(...)` to seed and verify data +- Use `injector.setExplicitInstance(mock, ServiceClass)` for mocking DI services +- Both injectors are automatically disposed after each test + ## E2E Testing with Playwright ### Test File Location @@ -278,9 +328,8 @@ test.describe('Authentication', () => { await page.locator('button', { hasText: 'Login' }).click() // Verify logged in state - const welcomeTitle = page.locator('hello-world div h2') - await expect(welcomeTitle).toBeVisible() - await expect(welcomeTitle).toHaveText('Hello, testuser !') + const dashboard = page.locator('page-dashboard') + await expect(dashboard).toBeVisible() // Logout const logoutButton = page.locator('shade-app-bar button >> text="Log Out"') diff --git a/.env.example b/.env.example index d63ae07..895b45f 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,36 @@ +# PostgreSQL connection string (required) DATABASE_URL=postgres://stackcraft:stackcraft@localhost:5433/stackcraft + +# Backend HTTP server port +# APP_SERVICE_PORT=9090 + +# MCP (Model Context Protocol) server port +# MCP_PORT=9091 + +# Allowed CORS origins (comma-separated) +# CORS_ORIGINS=http://localhost:8080 + +# Base64-encoded 256-bit key for encrypting sensitive values. +# If unset, a key file is auto-generated in ~/.stack-craft/ +# STACK_CRAFT_ENCRYPTION_KEY= + +# Minimum log level: verbose | debug | information | warning | error | fatal +# LOG_LEVEL=verbose + +# Timeout (ms) when stopping a service before sending SIGKILL +# STOP_TIMEOUT_MS=10000 + +# Timeout (ms) during shutdown before force-killing child processes +# SHUTDOWN_KILL_TIMEOUT_MS=5000 + +# Interval (ms) for flushing buffered process log lines to storage +# LOG_FLUSH_INTERVAL_MS=100 + +# MCP session time-to-live (ms) before automatic cleanup +# MCP_SESSION_TTL_MS=1800000 + +# Maximum number of concurrent MCP sessions +# MCP_MAX_SESSIONS=50 + +# Interval (ms) between MCP stale-session sweeps +# MCP_SESSION_SWEEP_MS=60000 diff --git a/.yarn/changelogs/common.76693fda.md b/.yarn/changelogs/common.76693fda.md new file mode 100644 index 0000000..23a6f91 --- /dev/null +++ b/.yarn/changelogs/common.76693fda.md @@ -0,0 +1,20 @@ + +# common + +## ♻️ Refactoring + +- Normalized service relations β€” extracted `prerequisiteIds` and `prerequisiteServiceIds` from `ServiceDefinition` into dedicated `ServiceDependencyLink` and `ServicePrerequisiteLink` join entities +- Added `ServiceRelations` type to `ServiceView`, separating relational data from entity definitions +- Added `mergeServiceView()` utility for composing a `ServiceView` from separate data-store entities with sensible defaults for missing pieces + +## πŸ“š Documentation + +- Added JSDoc to all API endpoint definitions across all API modules (identity, services, stacks, system, prerequisites, tokens, github-repositories, install) + +## πŸ§ͺ Tests + +- Added unit tests for `mergeServiceView()` covering full views, partial data, and missing relations + +## πŸ“¦ Build + +- Regenerated JSON schemas to reflect normalized model structure diff --git a/.yarn/changelogs/frontend.76693fda.md b/.yarn/changelogs/frontend.76693fda.md new file mode 100644 index 0000000..b770ad1 --- /dev/null +++ b/.yarn/changelogs/frontend.76693fda.md @@ -0,0 +1,23 @@ + +# frontend + +## πŸ› Bug Fixes + +- Fixed service URL resolution from `.env` file configuration +- Fixed service selection behavior in the services list + +## ♻️ Refactoring + +- Restructured layout components into `layout/` module (`sidebar`, `breadcrumbs`, `header`, `body`, `layout`) with barrel export +- Split monolithic `service-form.tsx` into focused subcomponents: `dependency-selector`, `prerequisite-selector`, and `file-list` +- Decomposed `service-detail.tsx` into a tabbed interface module with dedicated tabs (overview, configuration, logs, history) and an action bar +- Refactored dashboard into stack-oriented views (`stack-dashboard`, `stack-list-dashboard`, `service-row`) +- Split create-service wizard into `index.tsx` and `setup-step.tsx` +- Moved shared components (`github-logo`, `log-line`, `log-viewer`) into `shared/` module +- Removed standalone theme-switch component β€” theme selection moved to the settings page +- Refactored branch-selector component for positioning and scroll behavior + +## πŸ§ͺ Tests + +- Added unit tests for `session`, `theme-registry`, `install-service`, `environment-variable-service`, and `apply-client-find-options` +- Added form tests for `github-repo-form`, `prerequisite-form`, `stack-form`, `service-form/validators`, `create-admin-step`, `import-stack`, `api-tokens-section`, and `password-change-form` diff --git a/.yarn/changelogs/monaco-mfe.76693fda.md b/.yarn/changelogs/monaco-mfe.76693fda.md new file mode 100644 index 0000000..a4e57fa --- /dev/null +++ b/.yarn/changelogs/monaco-mfe.76693fda.md @@ -0,0 +1,15 @@ + +# monaco-mfe + +## ✨ Features + +- Added Monaco Editor Micro Frontend β€” a standalone MFE that the host frontend loads at runtime to render code editors +- Exposed `create()` / `destroy()` lifecycle API for mounting and unmounting the editor instance +- Added reactive `props` setter that applies theme, `readOnly`, and `value` changes without remounting the editor +- Added JSON schema validation support via `registerSchema()` for in-editor diagnostics +- Added runtime theme support with `applyTheme()`, accepting theme data from the host application + +## πŸ“¦ Build + +- Configured Vite library build outputting a single ES module (`dist/index.js`) with source maps +- Configured Monaco web workers for JSON language support via dynamic `import()` with Vite worker plugins diff --git a/.yarn/changelogs/service.76693fda.md b/.yarn/changelogs/service.76693fda.md new file mode 100644 index 0000000..e83eb01 --- /dev/null +++ b/.yarn/changelogs/service.76693fda.md @@ -0,0 +1,31 @@ + +# service + +## ✨ Features + +- Added `GET /system/health` endpoint returning database connectivity and overall system health status +- Added HTTP request logging middleware that logs method, URL, status, and duration with log level based on status code (verbose for 2xx/3xx, warning for 4xx, error for 5xx) + +## πŸ› Bug Fixes + +- Fixed session state persistence to PostgreSQL +- Fixed branch checkout behavior when switching service branches + +## ♻️ Refactoring + +- Normalized data model with `ServiceDependencyLink` and `ServicePrerequisiteLink` join tables, replacing inline arrays on `ServiceDefinition` +- Split `ProcessManager` into single-responsibility services: `ProcessRunner` (process lifecycle), `ServiceStatusManager` (state transitions), `StaleStateReconciler` (orphan detection), `ServiceGraphResolver` (dependency ordering), `ServiceEnvResolver` (environment variable resolution), and `ProcessIOAttacher` (stdout/stderr handling) +- Extracted `GitOperationsService` for pull, install, and build operations +- Added `DomainError` hierarchy (`NotFoundError`, `ConflictError`, `ValidationError`) for structured error handling across REST actions +- Added `FilteredConsoleLogger` for configurable log level filtering +- Added `getServiceOrThrow()` utility for consistent service lookup with typed error handling +- Updated import/export actions to handle normalized service relations (dependency and prerequisite links) +- Refactored data-store setup into dedicated `init-models`, `models`, and `db-options` modules + +## πŸ§ͺ Tests + +- Added unit tests for new services: `process-runner`, `service-env-resolver`, `service-graph-resolver`, `service-status-manager`, `stale-state-reconciler`, `git-head-watcher`, `git-watcher`, `websocket-service`, `shutdown-handler` +- Added tests for new utilities: `domain-error`, `filtered-console-logger`, `get-service-or-throw`, `resolve-path`, `resolve-service-cwd`, `encrypt-existing-secrets` +- Added REST action tests: `health-check`, `service-logs`, `service-history`, `clear-service-logs`, `prerequisite-lifecycle`, `evaluate-prerequisites`, `password-reset`, `validate-repo`, `get-service-status`, `post-install`, `setup-log-store`, `db-options` +- Added MCP tests: `mcp-server`, `setup-mcp`, `mcp-helpers`, `system-tools` +- Expanded existing test coverage for `process-manager`, `service-checkout`, `service-lifecycle`, `service-branches`, `check-prerequisite`, `import-export-actions`, `check-env-availability`, `tokens-rest-api`, `log-storage-service`, `git-service`, and `get-cors-options` diff --git a/.yarn/changelogs/stack-craft.76693fda.md b/.yarn/changelogs/stack-craft.76693fda.md new file mode 100644 index 0000000..a4057f3 --- /dev/null +++ b/.yarn/changelogs/stack-craft.76693fda.md @@ -0,0 +1,17 @@ + +# stack-craft + +## πŸ“š Documentation + +- Added troubleshooting guide covering database connectivity, port conflicts, and environment configuration issues +- Added `CONTRIBUTING.md` with development workflow and contribution guidelines +- Added `CODE_OF_CONDUCT.md` with Contributor Covenant +- Added `SECURITY.md` with vulnerability reporting procedures +- Added `.env.example` with documented environment variable defaults +- Expanded `README.md` with architecture overview and getting-started instructions + +## πŸ§ͺ Tests + +- Expanded E2E dogfooding test suite with prerequisite, repository, and service management flows +- Split E2E helpers into domain-specific modules (`login`, `notification`, `prerequisite`, `repository`, `service`, `sidebar`, `stack`) +- Refactored smoke tests for updated routing and layout structure diff --git a/.yarn/versions/76693fda.yml b/.yarn/versions/76693fda.yml new file mode 100644 index 0000000..067d6be --- /dev/null +++ b/.yarn/versions/76693fda.yml @@ -0,0 +1,6 @@ +releases: + common: patch + frontend: patch + monaco-mfe: patch + service: patch + stack-craft: patch diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..92c66d7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by opening an issue on the +[GitHub repository](https://github.com/furystack/stack-craft/issues). + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6b07069 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,135 @@ +# Contributing to Stack Craft + +## Development Setup + +1. Clone the repository and install dependencies: + +```bash +git clone https://github.com/furystack/stack-craft.git +cd stack-craft +yarn +``` + +2. Start PostgreSQL: + +```bash +docker compose up -d +``` + +3. Create your environment file: + +```bash +cp .env.example .env +``` + +4. Start the backend and frontend in separate terminals: + +```bash +yarn start:service # Terminal 1 +yarn start:frontend # Terminal 2 +``` + +## Project Structure + +``` +stack-craft/ +β”œβ”€β”€ common/ # Shared API type definitions, models, and generated schemas +β”œβ”€β”€ frontend/ # Shades-based SPA (Vite dev server on :8080) +β”œβ”€β”€ service/ # FuryStack REST backend (Node.js on :9090) +β”œβ”€β”€ e2e/ # Playwright end-to-end tests +└── docs/ # Documentation +``` + +## Code Style + +The project uses **ESLint** and **Prettier** for code quality and formatting. Both run automatically on staged files via Husky pre-commit hooks. + +```bash +yarn lint # Run ESLint +yarn format # Format all files with Prettier +yarn format:check # Check formatting without modifying files +``` + +Key conventions: + +- **File names:** kebab-case (e.g. `service-pipeline.ts`) +- **Components:** PascalCase exports in kebab-case files +- **Types:** Prefer `type` over `interface` +- **No `any`:** Use `unknown`, generics, or union types instead + +See `.cursor/rules/` for detailed coding guidelines. + +## Testing + +```bash +yarn test # Run Vitest unit tests with coverage +yarn test:e2e # Run Playwright E2E tests (requires running DB) +``` + +- Unit tests are co-located with source files (`*.spec.ts`) +- Coverage thresholds are enforced in `vitest.config.mts` +- Add tests for new functionality before submitting a PR + +## Schema Generation + +When you modify API types in `common/src/apis/` or models in `common/src/models/`, regenerate the JSON schemas: + +```bash +yarn build # Must build common first +yarn create-schemas # Regenerate schemas +``` + +## Versioning and Changelogs + +This project uses [Yarn's release workflow](https://yarnpkg.com/features/release-workflow) for version management and changelogs. Both are required before opening a PR. + +### 1. Bump versions + +Decide which packages need a version bump and at what level (patch/minor/major): + +```bash +yarn bumpVersions # Interactive prompt to select packages and bump type +``` + +This creates version manifest files that Yarn tracks. You can verify the state with: + +```bash +yarn version check # Validates that version bumps are configured +``` + +### 2. Create changelog drafts + +Generate changelog draft files for each bumped package: + +```bash +yarn changelog create # Creates .yarn/changelogs/{package}.{id}.md files +yarn changelog create -f # Force-recreate (useful if drafts are stale) +``` + +### 3. Fill the changelog entries + +Edit the generated `.yarn/changelogs/*.md` files. Each file has section placeholders -- fill in the relevant ones describing what changed and why. Write for end users, not as a git log. + +### 4. Validate + +```bash +yarn changelog check # Validates entries are filled and match the version type +``` + +### Applying releases (maintainers only) + +When merging to a release branch, the release pipeline runs: + +```bash +yarn applyReleaseChanges # Applies version bumps, merges changelogs, and formats +``` + +## Pull Request Process + +1. Create a feature branch from the latest `develop` +2. Make your changes with tests +3. Ensure `yarn build`, `yarn lint`, and `yarn test` all pass +4. Bump versions (`yarn bumpVersions`) and fill changelog entries (`yarn changelog create`, then edit the drafts) +5. Verify with `yarn changelog check` +6. Open a PR against `develop` +7. PRs must pass CI checks (build, lint, test, E2E, changelog check, version check) diff --git a/README.md b/README.md index 29d57ca..366121e 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,143 @@ # Stack Craft -Example web app with common type API definitions, a FuryStack-based backend service and a Shades-based single page application. +A web application for managing development stacks, services, prerequisites, and repositories. Built with a [FuryStack](https://github.com/furystack/furystack) backend and a [Shades](https://github.com/furystack/furystack/tree/develop/packages/shades)-based single page application. -# Usage +## Features -1. Clone the repository -1. Install the dependencies with `yarn` -1. Start PostgreSQL (e.g. `docker compose up -d`) -1. Copy `.env.example` to `.env` and adjust if needed -1. Start the frontend and the backend service with `yarn start` (you can stop / start them individually, check the NPM scripts for further details) +- **Stack management** -- group related services, repositories, and prerequisites into stacks +- **Service lifecycle** -- start, stop, restart, build, and install services with real-time log streaming +- **Prerequisite checks** -- define and evaluate system prerequisites before running services +- **Repository integration** -- clone and manage GitHub repositories with branch switching +- **Environment variables** -- manage per-stack and per-service environment configuration with encrypted secrets +- **Import / Export** -- share stack configurations across environments +- **MCP server** -- interact with Stack Craft via [Model Context Protocol](https://modelcontextprotocol.io/) for AI assistant integration + +## Prerequisites + +- [Node.js](https://nodejs.org/) >= 22 +- [Yarn](https://yarnpkg.com/) 4 (included via `packageManager` in `package.json`) +- [Docker](https://www.docker.com/) (for PostgreSQL, or provide your own instance) + +## Getting Started + +```bash +# Clone and install +git clone https://github.com/furystack/stack-craft.git +cd stack-craft +yarn + +# Start PostgreSQL +docker compose up -d + +# Create your environment file +cp .env.example .env + +# Start the backend and frontend +yarn start:service # Backend on http://localhost:9090 +yarn start:frontend # Frontend on http://localhost:8080 +``` + +Open http://localhost:8080 in your browser. On first launch, the installer wizard will guide you through creating an admin account. + +## Configuration + +Environment variables can be set in the `.env` file at the project root. + +| Variable | Default | Description | +| ---------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | `postgres://stackcraft:stackcraft@localhost:5433/stackcraft` | PostgreSQL connection string | +| `APP_SERVICE_PORT` | `9090` | Backend HTTP server port | +| `MCP_PORT` | `9091` | MCP server port | +| `STACK_CRAFT_ENCRYPTION_KEY` | _(auto-generated)_ | Base64-encoded 256-bit key for encrypting sensitive values. If unset, a key file is created in `~/.stack-craft/` | ## Docker -When running the Docker image, pass the `DATABASE_URL` environment variable: +Build and run the Docker image: + +```bash +docker build -t stack-craft . + +docker run -p 9090:9090 \ + -e DATABASE_URL=postgres://user:password@host:5432/stackcraft \ + stack-craft +``` + +The pre-built image is also available on Docker Hub: ```bash -docker run -e DATABASE_URL=postgres://user:password@host:5433/stackcraft furystack/stack-craft +docker run -p 9090:9090 \ + -e DATABASE_URL=postgres://user:password@host:5432/stackcraft \ + furystack/stack-craft +``` + +## Architecture + ``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Browser (Shades SPA) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ REST + WebSocket + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + MCP Clients ──HTTP+SSE──▢ Node.js Service :9090 β”‚ + :9091 β”‚ (FuryStack REST) β”‚ + β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” + β”‚ PostgreSQLβ”‚ β”‚ File System β”‚ + β”‚ β”‚ β”‚ (git, procs) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Project Structure + +``` +stack-craft/ +β”œβ”€β”€ common/ # Shared API type definitions and models +β”œβ”€β”€ frontend/ # Shades-based SPA (Vite) +β”œβ”€β”€ service/ # FuryStack REST backend (Node.js) +β”œβ”€β”€ docs/ # Documentation (troubleshooting, etc.) +└── e2e/ # Playwright end-to-end tests +``` + +See also: [frontend/README.md](frontend/README.md) for frontend-specific documentation. + +## Testing + +```bash +# Unit tests (Vitest) +yarn test + +# End-to-end tests (requires running PostgreSQL + DATABASE_URL) +yarn test:e2e +``` + +## Available Scripts + +| Script | Description | +| --------------------- | --------------------------------------------- | +| `yarn start:service` | Start the backend service | +| `yarn start:frontend` | Start the Vite dev server for the frontend | +| `yarn build` | Build all packages | +| `yarn test` | Run Vitest unit tests | +| `yarn test:e2e` | Run Playwright E2E tests | +| `yarn lint` | Run ESLint | +| `yarn format` | Format code with Prettier | +| `yarn create-schemas` | Regenerate JSON schemas from TypeScript types | + +## MCP Server + +Stack Craft includes a [Model Context Protocol](https://modelcontextprotocol.io/) server for AI assistant integration. It runs on a separate port (default `9091`) and provides tools for managing stacks, services, repositories, prerequisites, and environment variables. + +**Connecting:** Point your MCP client to `http://localhost:9091/mcp` using Streamable HTTP transport with a Bearer token for authentication. Create API tokens via the UI under User Settings. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding standards, and the release workflow. + +## Troubleshooting + +See [docs/troubleshooting.md](docs/troubleshooting.md) for common issues and their resolutions. -# Testing +## License -- You can execute the example Vitest tests with `yarn test` -- You can execute E2E tests with `yarn test:e2e` (requires a running PostgreSQL instance and `DATABASE_URL` set) +[GPL-2.0-only](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..61f8b2e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security Policy + +## Supported Versions + +Only the latest release published to Docker Hub is actively supported with security updates: + +| Version | Supported | +| ------------------------------ | --------- | +| `furystack/stack-craft:latest` | Yes | +| Older versions | No | + +We recommend always running the latest image to benefit from the most recent security patches. + +## Reporting a Vulnerability + +**Please do NOT open a public GitHub issue for security vulnerabilities.** + +Instead, report vulnerabilities privately by filing a security advisory: + +> [https://github.com/furystack/stack-craft/security/advisories/new](https://github.com/furystack/stack-craft/security/advisories/new) + +When reporting, please include: + +- A description of the vulnerability and its potential impact +- Steps to reproduce or a proof-of-concept +- Affected component(s) and version(s) +- Any suggested mitigation or fix + +## Response Timeline + +- **Acknowledgement**: within 72 hours of the report +- **Fix timeline**: within 2 weeks of confirmation, we will provide either a patch or a mitigation plan + +## Scope + +### In scope + +- Backend service (`service/`) +- Frontend application (`frontend/`) +- MCP server (`service/src/mcp/`) +- Authentication and session handling +- Encryption and secrets management + +### Out of scope + +- Third-party dependencies: please report vulnerabilities in upstream packages directly to their respective maintainers +- Issues in infrastructure or hosting environments not maintained by this project + +## Disclosure + +We follow coordinated disclosure. We ask that reporters allow us a reasonable window to address the issue before any public disclosure. diff --git a/common/schemas/entities.json b/common/schemas/entities.json index b302b64..224d9d5 100644 --- a/common/schemas/entities.json +++ b/common/schemas/entities.json @@ -120,20 +120,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -160,18 +146,7 @@ "type": "string" } }, - "required": [ - "id", - "stackName", - "displayName", - "description", - "prerequisiteIds", - "prerequisiteServiceIds", - "runCommand", - "files", - "createdAt", - "updatedAt" - ], + "required": ["id", "stackName", "displayName", "description", "runCommand", "files", "createdAt", "updatedAt"], "additionalProperties": false, "description": "Shareable service definition. Contains the immutable description of a service and its commands. Included in stack exports and shared between installations." }, @@ -355,10 +330,42 @@ ], "description": "Full stack view combining definition and config for API responses" }, + "ServiceRelations": { + "type": "object", + "properties": { + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["prerequisiteIds", "prerequisiteServiceIds"], + "additionalProperties": false, + "description": "Prerequisite and dependency relationships resolved from join tables" + }, "ServiceView": { "type": "object", "additionalProperties": false, "properties": { + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } + }, "serviceId": { "type": "string", "description": "FK to {@link ServiceDefinition.id }" @@ -453,20 +460,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -509,7 +502,7 @@ "stackName", "updatedAt" ], - "description": "Full service view combining definition, config, status, and git state for API responses" + "description": "Full service view combining definition, config, status, git state, and relations for API responses" }, "User": { "type": "object", @@ -607,6 +600,26 @@ "additionalProperties": false, "description": "Audit log entry for service state transitions. Records every lifecycle event (start, stop, crash, install, build, pull) with full context: who triggered it, how, and any relevant metadata. Entries are never deleted and not included in exports." }, + "ServicePrerequisiteLink": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Deterministic PK: `${serviceId}::${prerequisiteId}`" + }, + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "prerequisiteId": { + "type": "string", + "description": "FK to {@link Prerequisite.id }" + } + }, + "required": ["id", "serviceId", "prerequisiteId"], + "additionalProperties": false, + "description": "Join table linking a {@link ServiceDefinition } to a {@link Prerequisite }" + }, "ServiceLogEntry": { "type": "object", "properties": { @@ -633,6 +646,26 @@ "required": ["id", "serviceId", "processUid", "stream", "line", "createdAt"], "additionalProperties": false }, + "ServiceDependencyLink": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Deterministic PK: `${serviceId}::${dependsOnServiceId}`" + }, + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id } -- the dependent service" + }, + "dependsOnServiceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id } -- the service that must run first" + } + }, + "required": ["id", "serviceId", "dependsOnServiceId"], + "additionalProperties": false, + "description": "Join table linking a {@link ServiceDefinition } to another service it depends on" + }, "PublicApiToken": { "type": "object", "properties": { diff --git a/common/schemas/github-repositories-api.json b/common/schemas/github-repositories-api.json index 0814bcb..83b599c 100644 --- a/common/schemas/github-repositories-api.json +++ b/common/schemas/github-repositories-api.json @@ -39,7 +39,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Registers a new GitHub repository" }, "GitHubRepository": { "type": "object", @@ -181,7 +182,8 @@ } }, "required": ["url", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Checks whether the repository is accessible (e.g. URL is valid and reachable)" }, "GitHubRepositoriesApi": { "type": "object", diff --git a/common/schemas/identity-api.json b/common/schemas/identity-api.json index 03ed913..228c6db 100644 --- a/common/schemas/identity-api.json +++ b/common/schemas/identity-api.json @@ -16,7 +16,8 @@ } }, "required": ["result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Checks whether the current session is authenticated" }, "GetCurrentUserAction": { "type": "object", @@ -26,7 +27,8 @@ } }, "required": ["result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Returns the currently authenticated user's profile" }, "User": { "type": "object", @@ -65,7 +67,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Authenticates with username/password and returns the user profile" }, "LogoutAction": { "type": "object", @@ -73,7 +76,8 @@ "result": {} }, "required": ["result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Ends the current session" }, "PasswordResetAction": { "type": "object", @@ -103,7 +107,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Changes the current user's password" }, "IdentityApi": { "type": "object", diff --git a/common/schemas/install-api.json b/common/schemas/install-api.json index c46e57d..4ec367f 100644 --- a/common/schemas/install-api.json +++ b/common/schemas/install-api.json @@ -23,7 +23,8 @@ } }, "required": ["result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Returns whether the application needs initial setup or is already installed" }, "InstallAction": { "type": "object", @@ -53,7 +54,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Performs initial application setup and creates the first admin user" }, "InstallApi": { "type": "object", diff --git a/common/schemas/prerequisites-api.json b/common/schemas/prerequisites-api.json index 0ecbcab..62184a2 100644 --- a/common/schemas/prerequisites-api.json +++ b/common/schemas/prerequisites-api.json @@ -145,7 +145,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Creates a new prerequisite definition" }, "Prerequisite": { "type": "object", @@ -299,7 +300,8 @@ } }, "required": ["url", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Executes the prerequisite's check command and returns whether it is satisfied" }, "PrerequisitesApi": { "type": "object", diff --git a/common/schemas/services-api.json b/common/schemas/services-api.json index c623823..9b6b516 100644 --- a/common/schemas/services-api.json +++ b/common/schemas/services-api.json @@ -28,20 +28,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -62,16 +48,7 @@ "description": "Shared files placed relative to the service root" } }, - "required": [ - "id", - "stackName", - "displayName", - "description", - "prerequisiteIds", - "prerequisiteServiceIds", - "runCommand", - "files" - ], + "required": ["id", "stackName", "displayName", "description", "runCommand", "files"], "additionalProperties": false }, "ServiceFile": { @@ -159,6 +136,18 @@ "type": "object", "additionalProperties": false, "properties": { + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } + }, "autoFetchEnabled": { "type": "boolean", "description": "Whether automatic git fetch is enabled" @@ -209,20 +198,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -253,8 +228,6 @@ "files", "id", "localFiles", - "prerequisiteIds", - "prerequisiteServiceIds", "runCommand", "stackName" ] @@ -270,12 +243,25 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Creates a new service definition with optional configuration" }, "ServiceView": { "type": "object", "additionalProperties": false, "properties": { + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } + }, "serviceId": { "type": "string", "description": "FK to {@link ServiceDefinition.id }" @@ -370,20 +356,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -426,7 +398,7 @@ "stackName", "updatedAt" ], - "description": "Full service view combining definition, config, status, and git state for API responses" + "description": "Full service view combining definition, config, status, git state, and relations for API responses" }, "CloneStatus": { "type": "string", @@ -472,20 +444,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -530,6 +488,18 @@ "$ref": "#/definitions/ServiceFile" }, "description": "Per-installation secret files, encrypted at rest. NOT included in exports. When a local file shares a `relativePath` with a shared file from {@link ServiceDefinition.files } , the local file takes precedence at apply time." + }, + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -541,8 +511,6 @@ "environmentVariableOverrides", "files", "localFiles", - "prerequisiteIds", - "prerequisiteServiceIds", "runCommand", "stackName" ] @@ -580,20 +548,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -638,6 +592,18 @@ "$ref": "#/definitions/ServiceFile" }, "description": "Per-installation secret files, encrypted at rest. NOT included in exports. When a local file shares a `relativePath` with a shared file from {@link ServiceDefinition.files } , the local file takes precedence at apply time." + }, + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } } }, "additionalProperties": false @@ -689,7 +655,8 @@ } }, "required": ["url", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Triggers a lifecycle action (start, stop, restart, install, build, pull, setup, update) on a service" }, "ApplyServiceFilesEndpoint": { "type": "object", @@ -734,7 +701,8 @@ } }, "required": ["url", "body", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Writes shared and local service files to disk, optionally for a single file" }, "ServiceLogsEndpoint": { "type": "object", @@ -779,7 +747,8 @@ } }, "required": ["url", "query", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Retrieves recent log entries for a service, optionally filtered by process UID or search text" }, "ServiceLogEntry": { "type": "object", @@ -832,7 +801,8 @@ } }, "required": ["url", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Deletes all stored log entries for a service" }, "ServiceHistoryEndpoint": { "type": "object", @@ -871,7 +841,8 @@ } }, "required": ["url", "query", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Retrieves the state transition history for a service" }, "ServiceStateHistory": { "type": "object", @@ -990,7 +961,8 @@ } }, "required": ["url", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Lists local and remote branches for the service's linked repository" }, "ServiceCheckoutEndpoint": { "type": "object", @@ -1030,7 +1002,8 @@ } }, "required": ["url", "body", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Switches the service's repository to a different branch" }, "ServicesApi": { "type": "object", @@ -1299,7 +1272,7 @@ "type": "object", "properties": { "findOptions": { - "$ref": "#/definitions/FindOptions%3CServiceView%2C(%22id%22%7C%22stackName%22%7C%22displayName%22%7C%22description%22%7C%22workingDirectory%22%7C%22repositoryId%22%7C%22prerequisiteIds%22%7C%22prerequisiteServiceIds%22%7C%22installCommand%22%7C%22buildCommand%22%7C%22runCommand%22%7C%22files%22%7C%22createdAt%22%7C%22updatedAt%22%7C%22serviceId%22%7C%22autoFetchEnabled%22%7C%22autoFetchIntervalMinutes%22%7C%22autoRestartOnFetch%22%7C%22environmentVariableOverrides%22%7C%22localFiles%22%7C%22cloneStatus%22%7C%22installStatus%22%7C%22buildStatus%22%7C%22runStatus%22%7C%22lastClonedAt%22%7C%22lastInstalledAt%22%7C%22lastBuiltAt%22%7C%22lastStartedAt%22%7C%22lastFetchedAt%22%7C%22currentBranch%22%7C%22commitsBehind%22)%5B%5D%3E" + "$ref": "#/definitions/FindOptions%3CServiceView%2C(%22id%22%7C%22stackName%22%7C%22displayName%22%7C%22description%22%7C%22workingDirectory%22%7C%22repositoryId%22%7C%22installCommand%22%7C%22buildCommand%22%7C%22runCommand%22%7C%22files%22%7C%22createdAt%22%7C%22updatedAt%22%7C%22serviceId%22%7C%22autoFetchEnabled%22%7C%22autoFetchIntervalMinutes%22%7C%22autoRestartOnFetch%22%7C%22environmentVariableOverrides%22%7C%22localFiles%22%7C%22cloneStatus%22%7C%22installStatus%22%7C%22buildStatus%22%7C%22runStatus%22%7C%22lastClonedAt%22%7C%22lastInstalledAt%22%7C%22lastBuiltAt%22%7C%22lastStartedAt%22%7C%22lastFetchedAt%22%7C%22currentBranch%22%7C%22commitsBehind%22%7C%22prerequisiteIds%22%7C%22prerequisiteServiceIds%22)%5B%5D%3E" } }, "additionalProperties": false @@ -1312,7 +1285,7 @@ "additionalProperties": false, "description": "Rest endpoint model for getting / querying collections" }, - "FindOptions": { + "FindOptions": { "type": "object", "properties": { "top": { @@ -1350,14 +1323,6 @@ "type": "string", "enum": ["ASC", "DESC"] }, - "prerequisiteIds": { - "type": "string", - "enum": ["ASC", "DESC"] - }, - "prerequisiteServiceIds": { - "type": "string", - "enum": ["ASC", "DESC"] - }, "installCommand": { "type": "string", "enum": ["ASC", "DESC"] @@ -1449,6 +1414,14 @@ "commitsBehind": { "type": "string", "enum": ["ASC", "DESC"] + }, + "prerequisiteIds": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "prerequisiteServiceIds": { + "type": "string", + "enum": ["ASC", "DESC"] } }, "additionalProperties": false, @@ -1465,8 +1438,6 @@ "description", "workingDirectory", "repositoryId", - "prerequisiteIds", - "prerequisiteServiceIds", "installCommand", "buildCommand", "runCommand", @@ -1489,7 +1460,9 @@ "lastStartedAt", "lastFetchedAt", "currentBranch", - "commitsBehind" + "commitsBehind", + "prerequisiteIds", + "prerequisiteServiceIds" ] }, "description": "The result set will be limited to these fields" @@ -1874,106 +1847,6 @@ } ] }, - "prerequisiteIds": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "$ne": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - } - }, - "$nin": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - } - } - }, - "additionalProperties": false - } - ] - }, - "prerequisiteServiceIds": { - "anyOf": [ - { - "type": "object", - "properties": { - "$eq": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, - "$ne": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - } - }, - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "$in": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - } - }, - "$nin": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - } - } - }, - "additionalProperties": false - } - ] - }, "installCommand": { "anyOf": [ { @@ -3143,6 +3016,98 @@ "additionalProperties": false } ] + }, + "prerequisiteIds": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "string" + } + }, + "$ne": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "prerequisiteServiceIds": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "string" + } + }, + "$ne": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] } } }, @@ -3182,8 +3147,6 @@ "description", "workingDirectory", "repositoryId", - "prerequisiteIds", - "prerequisiteServiceIds", "installCommand", "buildCommand", "runCommand", @@ -3206,7 +3169,9 @@ "lastStartedAt", "lastFetchedAt", "currentBranch", - "commitsBehind" + "commitsBehind", + "prerequisiteIds", + "prerequisiteServiceIds" ] }, "description": "The list of fields to select" diff --git a/common/schemas/stacks-api.json b/common/schemas/stacks-api.json index 6139127..18a3dfb 100644 --- a/common/schemas/stacks-api.json +++ b/common/schemas/stacks-api.json @@ -63,7 +63,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Creates a new stack with optional configuration" }, "StackView": { "type": "object", @@ -219,195 +220,177 @@ } }, "required": ["line", "pattern", "snippet", "source"], - "additionalProperties": false + "additionalProperties": false, + "description": "A warning about a potential secret detected during stack export" }, - "ExportStackEndpoint": { + "ExportStackResult": { "type": "object", "properties": { - "url": { + "stack": { "type": "object", "properties": { - "id": { - "type": "string" + "name": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this stack does" } }, - "required": ["id"], + "required": ["name", "displayName", "description"], "additionalProperties": false }, - "result": { - "type": "object", - "properties": { - "stack": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique kebab-case identifier for the stack" - }, - "displayName": { - "type": "string", - "description": "Human-readable name shown in the UI" - }, - "description": { - "type": "string", - "description": "Optional description of what this stack does" + "services": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" } }, - "required": ["name", "displayName", "description"], - "additionalProperties": false - }, - "services": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "UUID primary key" - }, - "stackName": { - "type": "string", - "description": "FK to {@link StackDefinition.name }" - }, - "displayName": { - "type": "string", - "description": "Human-readable name shown in the UI" - }, - "description": { - "type": "string", - "description": "Optional description of what this service does" - }, - "workingDirectory": { - "type": "string", - "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" - }, - "repositoryId": { - "type": "string", - "description": "Optional FK to {@link GitHubRepository.id }" - }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, - "installCommand": { - "type": "string", - "description": "Shell command to install dependencies (e.g. \"npm install\")" - }, - "buildCommand": { - "type": "string", - "description": "Shell command to build the service (e.g. \"npm run build\")" - }, - "runCommand": { - "type": "string", - "description": "Shell command to run the service (e.g. \"npm start\")" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/definitions/ServiceFile" - }, - "description": "Shared files placed relative to the service root" - } + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "installCommand": { + "type": "string", + "description": "Shell command to install dependencies (e.g. \"npm install\")" + }, + "buildCommand": { + "type": "string", + "description": "Shell command to build the service (e.g. \"npm run build\")" + }, + "runCommand": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/ServiceFile" }, - "required": [ - "id", - "stackName", - "displayName", - "description", - "prerequisiteIds", - "prerequisiteServiceIds", - "runCommand", - "files" - ], - "additionalProperties": false + "description": "Shared files placed relative to the service root" } }, - "repositories": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "UUID primary key" - }, - "stackName": { - "type": "string", - "description": "FK to {@link StackDefinition.name }" - }, - "url": { - "type": "string", - "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" - }, - "displayName": { - "type": "string", - "description": "Human-readable name shown in the UI" - }, - "description": { - "type": "string", - "description": "Optional description" - } - }, - "required": ["id", "stackName", "url", "displayName", "description"], - "additionalProperties": false + "required": [ + "description", + "displayName", + "files", + "id", + "prerequisiteIds", + "prerequisiteServiceIds", + "runCommand", + "stackName" + ] + } + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" } }, - "prerequisites": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "UUID primary key" - }, - "stackName": { - "type": "string", - "description": "FK to {@link StackDefinition.name }" - }, - "name": { - "type": "string", - "description": "Human-readable prerequisite name (e.g. \"Node.js >= 18\")" - }, - "type": { - "$ref": "#/definitions/PrerequisiteType", - "description": "Discriminator that determines the check logic and config shape" - }, - "config": { - "$ref": "#/definitions/PrerequisiteConfig", - "description": "Type-specific configuration (stored as JSON TEXT in the database)" - }, - "installationHelp": { - "type": "string", - "description": "Help text shown when the prerequisite check fails" - } - }, - "required": ["id", "stackName", "name", "type", "config", "installationHelp"], - "additionalProperties": false + "required": ["id", "stackName", "url", "displayName", "description"], + "additionalProperties": false + } + }, + "prerequisites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable prerequisite name (e.g. \"Node.js >= 18\")" + }, + "type": { + "$ref": "#/definitions/PrerequisiteType", + "description": "Discriminator that determines the check logic and config shape" + }, + "config": { + "$ref": "#/definitions/PrerequisiteConfig", + "description": "Type-specific configuration (stored as JSON TEXT in the database)" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the prerequisite check fails" } }, - "warnings": { - "type": "array", - "items": { - "$ref": "#/definitions/SecretWarning" - } - } - }, - "required": ["stack", "services", "repositories", "prerequisites"], - "additionalProperties": false + "required": ["id", "stackName", "name", "type", "config", "installationHelp"], + "additionalProperties": false + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/SecretWarning" + } } }, - "required": ["url", "result"], + "required": ["stack", "services", "repositories", "prerequisites"], "additionalProperties": false }, "ServiceFile": { @@ -528,6 +511,27 @@ ], "description": "Union of all possible prerequisite config shapes." }, + "ExportStackEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/ExportStackResult" + } + }, + "required": ["url", "result"], + "additionalProperties": false, + "description": "Exports a stack and all its associated services, repositories, and prerequisites for sharing" + }, "ImportStackEndpoint": { "type": "object", "properties": { @@ -573,7 +577,20 @@ "type": "array", "items": { "type": "object", + "additionalProperties": false, "properties": { + "prerequisiteIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "prerequisiteServiceIds": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string", "description": "UUID primary key" @@ -598,20 +615,6 @@ "type": "string", "description": "Optional FK to {@link GitHubRepository.id }" }, - "prerequisiteIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of {@link Prerequisite } entities required by this service" - }, - "prerequisiteServiceIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "IDs of other {@link ServiceDefinition } entities that must be running first" - }, "installCommand": { "type": "string", "description": "Shell command to install dependencies (e.g. \"npm install\")" @@ -633,16 +636,15 @@ } }, "required": [ - "id", - "stackName", - "displayName", "description", + "displayName", + "files", + "id", "prerequisiteIds", "prerequisiteServiceIds", "runCommand", - "files" - ], - "additionalProperties": false + "stackName" + ] } }, "repositories": { @@ -764,7 +766,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Imports a previously exported stack, creating all associated entities and applying configuration overrides" }, "StackSetupEndpoint": { "type": "object", @@ -791,7 +794,8 @@ } }, "required": ["url", "result"], - "additionalProperties": false + "additionalProperties": false, + "description": "Runs the setup pipeline (clone, install, build) for all services in a stack" }, "StacksApi": { "type": "object", diff --git a/common/schemas/system-api.json b/common/schemas/system-api.json index af53375..b65a811 100644 --- a/common/schemas/system-api.json +++ b/common/schemas/system-api.json @@ -1,6 +1,39 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "HealthCheckResult": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["ok", "degraded"] + }, + "uptime": { + "type": "number" + }, + "version": { + "type": "string" + }, + "database": { + "type": "string", + "enum": ["connected", "disconnected"] + } + }, + "required": ["status", "uptime", "version", "database"], + "additionalProperties": false, + "description": "Backend health status including uptime and database connectivity" + }, + "HealthCheckEndpoint": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/HealthCheckResult" + } + }, + "required": ["result"], + "additionalProperties": false, + "description": "Returns the current health status of the service" + }, "CheckEnvAvailabilityEndpoint": { "type": "object", "properties": { @@ -25,40 +58,21 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Checks whether the specified environment variables are set on the host system" }, "SystemApi": { "type": "object", "properties": { "GET": { "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "result": {}, - "url": {}, - "query": {}, - "body": {}, - "headers": {}, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "deprecated": { - "type": "boolean" - }, - "summary": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": ["result"], - "additionalProperties": false - } + "properties": { + "/system/health": { + "$ref": "#/definitions/HealthCheckEndpoint" + } + }, + "required": ["/system/health"], + "additionalProperties": false }, "POST": { "type": "object", @@ -281,7 +295,7 @@ } } }, - "required": ["POST"], + "required": ["GET", "POST"], "additionalProperties": false } } diff --git a/common/schemas/tokens-api.json b/common/schemas/tokens-api.json index 5f768cf..29a0d4e 100644 --- a/common/schemas/tokens-api.json +++ b/common/schemas/tokens-api.json @@ -29,7 +29,8 @@ } }, "required": ["result", "body"], - "additionalProperties": false + "additionalProperties": false, + "description": "Creates a new API token and returns the plain-text value (shown only once)" }, "PublicApiToken": { "type": "object", diff --git a/common/src/apis/github-repositories.ts b/common/src/apis/github-repositories.ts index 975dba5..e331580 100644 --- a/common/src/apis/github-repositories.ts +++ b/common/src/apis/github-repositories.ts @@ -1,9 +1,15 @@ +/** + * REST API type definitions for GitHub repository management. + * Repositories are linked to services and used for cloning, fetching, and branch tracking. + */ + import type { WithOptionalId } from '@furystack/core' import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' import type { GitHubRepository } from '../models/github-repository.js' export type GitHubRepoWritableFields = Omit +/** Registers a new GitHub repository */ export type PostGitHubRepoEndpoint = { result: GitHubRepository body: WithOptionalId @@ -11,6 +17,7 @@ export type PostGitHubRepoEndpoint = { export type PatchGitHubRepoEndpoint = PatchEndpoint +/** Checks whether the repository is accessible (e.g. URL is valid and reachable) */ export type ValidateRepoEndpoint = { url: { id: string } result: { accessible: boolean; message?: string } diff --git a/common/src/apis/identity.ts b/common/src/apis/identity.ts index 8e1039a..c8ab9b6 100644 --- a/common/src/apis/identity.ts +++ b/common/src/apis/identity.ts @@ -1,11 +1,19 @@ import type { RestApi } from '@furystack/rest' import type { User } from '../models/user.js' +/** Checks whether the current session is authenticated */ export type IsAuthenticatedAction = { result: { isAuthenticated: boolean } } + +/** Returns the currently authenticated user's profile */ export type GetCurrentUserAction = { result: User } + +/** Authenticates with username/password and returns the user profile */ export type LoginAction = { result: User; body: { username: string; password: string } } + +/** Ends the current session */ export type LogoutAction = { result: unknown } +/** Changes the current user's password */ export type PasswordResetAction = { result: { success: boolean } body: { diff --git a/common/src/apis/install.ts b/common/src/apis/install.ts index 7823211..d943ec7 100644 --- a/common/src/apis/install.ts +++ b/common/src/apis/install.ts @@ -6,8 +6,10 @@ export type InstallStateResponse = { state: InstallState } +/** Returns whether the application needs initial setup or is already installed */ export type GetServiceStatusAction = { result: InstallStateResponse } +/** Performs initial application setup and creates the first admin user */ export type InstallAction = { result: { success: boolean }; body: { username: string; password: string } } export interface InstallApi extends RestApi { diff --git a/common/src/apis/prerequisites.ts b/common/src/apis/prerequisites.ts index 0246c3a..a10d5c5 100644 --- a/common/src/apis/prerequisites.ts +++ b/common/src/apis/prerequisites.ts @@ -1,9 +1,15 @@ +/** + * REST API type definitions for prerequisite management. + * Prerequisites represent system-level dependencies (e.g. Node.js, Docker) that services require. + */ + import type { WithOptionalId } from '@furystack/core' import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' import type { Prerequisite } from '../models/prerequisite.js' export type PrerequisiteWritableFields = Omit +/** Creates a new prerequisite definition */ export type PostPrerequisiteEndpoint = { result: Prerequisite body: WithOptionalId @@ -11,6 +17,7 @@ export type PostPrerequisiteEndpoint = { export type PatchPrerequisiteEndpoint = PatchEndpoint +/** Executes the prerequisite's check command and returns whether it is satisfied */ export type CheckPrerequisiteEndpoint = { url: { id: string } result: { satisfied: boolean; output: string } diff --git a/common/src/apis/services.ts b/common/src/apis/services.ts index b6b8aff..290d9f0 100644 --- a/common/src/apis/services.ts +++ b/common/src/apis/services.ts @@ -8,8 +8,13 @@ import type { ServiceView } from '../models/views.js' export type ServiceDefinitionWritableFields = Omit export type ServiceConfigWritableFields = Omit -export type ServiceWritableFields = ServiceDefinitionWritableFields & Omit +export type ServiceWritableFields = ServiceDefinitionWritableFields & + Omit & { + prerequisiteIds?: string[] + prerequisiteServiceIds?: string[] + } +/** Creates a new service definition with optional configuration */ export type PostServiceEndpoint = { result: ServiceView body: WithOptionalId @@ -17,36 +22,43 @@ export type PostServiceEndpoint = { export type PatchServiceEndpoint = PatchEndpoint +/** Triggers a lifecycle action (start, stop, restart, install, build, pull, setup, update) on a service */ export type ServiceActionEndpoint = { url: { id: string }; result: { success: boolean; serviceId: string } } +/** Writes shared and local service files to disk, optionally for a single file */ export type ApplyServiceFilesEndpoint = { url: { id: string } body: { relativePath?: string } result: { success: boolean; serviceId: string; applied: string[] } } +/** Retrieves recent log entries for a service, optionally filtered by process UID or search text */ export type ServiceLogsEndpoint = { url: { id: string } query: { lines?: number; processUid?: string; search?: string } result: { entries: ServiceLogEntry[] } } +/** Deletes all stored log entries for a service */ export type ClearServiceLogsEndpoint = { url: { id: string } result: { success: boolean } } +/** Retrieves the state transition history for a service */ export type ServiceHistoryEndpoint = { url: { id: string } query: { limit?: number } result: { entries: ServiceStateHistory[] } } +/** Lists local and remote branches for the service's linked repository */ export type ServiceBranchesEndpoint = { url: { id: string } result: { currentBranch: string; local: string[]; remote: string[] } } +/** Switches the service's repository to a different branch */ export type ServiceCheckoutEndpoint = { url: { id: string } body: { branch: string } diff --git a/common/src/apis/stacks.ts b/common/src/apis/stacks.ts index 85b264f..56123f9 100644 --- a/common/src/apis/stacks.ts +++ b/common/src/apis/stacks.ts @@ -1,25 +1,36 @@ +/** + * REST API type definitions for stack management endpoints. + * Stacks group related services, repositories, and prerequisites into a deployable unit. + */ + import type { WithOptionalId } from '@furystack/core' import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' import type { EnvironmentVariableValue } from '../models/environment-variable-value.js' import type { GitHubRepository } from '../models/github-repository.js' import type { Prerequisite } from '../models/prerequisite.js' import type { ServiceConfig } from '../models/service-config.js' -import type { ServiceFile } from '../models/service-definition.js' -import type { ServiceDefinition } from '../models/service-definition.js' +import type { ServiceDefinition, ServiceFile } from '../models/service-definition.js' import type { StackConfig } from '../models/stack-config.js' import type { StackDefinition } from '../models/stack-definition.js' import type { StackView } from '../models/views.js' export type StackWritableFields = Omit & Omit + +/** Creates a new stack with optional configuration */ export type PostStackEndpoint = { result: StackView; body: WithOptionalId } + export type PatchStackEndpoint = PatchEndpoint type ShareableStackDefinition = Omit -type ShareableServiceDefinition = Omit +type ShareableServiceDefinition = Omit & { + prerequisiteIds: string[] + prerequisiteServiceIds: string[] +} type ShareableGitHubRepository = Omit type ShareablePrerequisite = Omit +/** A warning about a potential secret detected during stack export */ export type SecretWarning = { line: number pattern: string @@ -28,17 +39,21 @@ export type SecretWarning = { suggestion?: string } +export type ExportStackResult = { + stack: ShareableStackDefinition + services: ShareableServiceDefinition[] + repositories: ShareableGitHubRepository[] + prerequisites: ShareablePrerequisite[] + warnings?: SecretWarning[] +} + +/** Exports a stack and all its associated services, repositories, and prerequisites for sharing */ export type ExportStackEndpoint = { url: { id: string } - result: { - stack: ShareableStackDefinition - services: ShareableServiceDefinition[] - repositories: ShareableGitHubRepository[] - prerequisites: ShareablePrerequisite[] - warnings?: SecretWarning[] - } + result: ExportStackResult } +/** Imports a previously exported stack, creating all associated entities and applying configuration overrides */ export type ImportStackEndpoint = { result: { success: boolean; warnings?: SecretWarning[] } body: { @@ -60,6 +75,7 @@ export type ImportStackEndpoint = { } } +/** Runs the setup pipeline (clone, install, build) for all services in a stack */ export type StackSetupEndpoint = { url: { id: string } result: { success: boolean } diff --git a/common/src/apis/system.ts b/common/src/apis/system.ts index 9e97ed1..a5199a5 100644 --- a/common/src/apis/system.ts +++ b/common/src/apis/system.ts @@ -1,11 +1,26 @@ import type { RestApi } from '@furystack/rest' +/** Backend health status including uptime and database connectivity */ +export type HealthCheckResult = { + status: 'ok' | 'degraded' + uptime: number + version: string + database: 'connected' | 'disconnected' +} + +/** Returns the current health status of the service */ +export type HealthCheckEndpoint = { result: HealthCheckResult } + +/** Checks whether the specified environment variables are set on the host system */ export type CheckEnvAvailabilityEndpoint = { result: Record body: { variableNames: string[] } } export interface SystemApi extends RestApi { + GET: { + '/system/health': HealthCheckEndpoint + } POST: { '/system/check-env-availability': CheckEnvAvailabilityEndpoint } diff --git a/common/src/apis/tokens.ts b/common/src/apis/tokens.ts index fba8f2e..430eb3e 100644 --- a/common/src/apis/tokens.ts +++ b/common/src/apis/tokens.ts @@ -1,7 +1,13 @@ +/** + * REST API type definitions for API token management. + * Tokens are used to authenticate external clients (e.g. MCP) against the Stack Craft API. + */ + import type { DeleteEndpoint, GetCollectionEndpoint, RestApi } from '@furystack/rest' import type { ApiToken } from '../models/api-token.js' import type { PublicApiToken } from '../models/public-api-token.js' +/** Creates a new API token and returns the plain-text value (shown only once) */ export type CreateTokenEndpoint = { result: { token: PublicApiToken; plainTextToken: string } body: { name: string } diff --git a/common/src/index.ts b/common/src/index.ts index 4f8d99c..d4a0f19 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -1,3 +1,4 @@ export * from './models/index.js' export * from './apis/index.js' export * from './utils/service-path-utils.js' +export * from './utils/merge-service-view.js' diff --git a/common/src/models/index.ts b/common/src/models/index.ts index 6b9ced6..ceccfb4 100644 --- a/common/src/models/index.ts +++ b/common/src/models/index.ts @@ -13,4 +13,6 @@ export * from './prerequisite.js' export * from './prerequisite-check-result.js' export * from './api-token.js' export * from './public-api-token.js' +export * from './service-prerequisite-link.js' +export * from './service-dependency-link.js' export * from './views.js' diff --git a/common/src/models/service-definition.ts b/common/src/models/service-definition.ts index bfb6f5e..100c8c0 100644 --- a/common/src/models/service-definition.ts +++ b/common/src/models/service-definition.ts @@ -32,12 +32,6 @@ export class ServiceDefinition { /** Optional FK to {@link GitHubRepository.id} */ repositoryId?: string - /** IDs of {@link Prerequisite} entities required by this service */ - prerequisiteIds: string[] = [] - - /** IDs of other {@link ServiceDefinition} entities that must be running first */ - prerequisiteServiceIds: string[] = [] - /** Shell command to install dependencies (e.g. "npm install") */ installCommand?: string diff --git a/common/src/models/service-dependency-link.ts b/common/src/models/service-dependency-link.ts new file mode 100644 index 0000000..c4a979c --- /dev/null +++ b/common/src/models/service-dependency-link.ts @@ -0,0 +1,11 @@ +/** Join table linking a {@link ServiceDefinition} to another service it depends on */ +export class ServiceDependencyLink { + /** Deterministic PK: `${serviceId}::${dependsOnServiceId}` */ + id!: string + + /** FK to {@link ServiceDefinition.id} -- the dependent service */ + serviceId!: string + + /** FK to {@link ServiceDefinition.id} -- the service that must run first */ + dependsOnServiceId!: string +} diff --git a/common/src/models/service-prerequisite-link.ts b/common/src/models/service-prerequisite-link.ts new file mode 100644 index 0000000..7ac0b4f --- /dev/null +++ b/common/src/models/service-prerequisite-link.ts @@ -0,0 +1,11 @@ +/** Join table linking a {@link ServiceDefinition} to a {@link Prerequisite} */ +export class ServicePrerequisiteLink { + /** Deterministic PK: `${serviceId}::${prerequisiteId}` */ + id!: string + + /** FK to {@link ServiceDefinition.id} */ + serviceId!: string + + /** FK to {@link Prerequisite.id} */ + prerequisiteId!: string +} diff --git a/common/src/models/views.ts b/common/src/models/views.ts index 7e6a7f9..8494367 100644 --- a/common/src/models/views.ts +++ b/common/src/models/views.ts @@ -8,5 +8,11 @@ import type { ServiceStatus } from './service-status.js' /** Full stack view combining definition and config for API responses */ export type StackView = StackDefinition & StackConfig -/** Full service view combining definition, config, status, and git state for API responses */ -export type ServiceView = ServiceDefinition & ServiceConfig & ServiceStatus & ServiceGitStatus +/** Prerequisite and dependency relationships resolved from join tables */ +export type ServiceRelations = { + prerequisiteIds: string[] + prerequisiteServiceIds: string[] +} + +/** Full service view combining definition, config, status, git state, and relations for API responses */ +export type ServiceView = ServiceDefinition & ServiceConfig & ServiceStatus & ServiceGitStatus & ServiceRelations diff --git a/common/src/utils/merge-service-view.spec.ts b/common/src/utils/merge-service-view.spec.ts new file mode 100644 index 0000000..d6804fe --- /dev/null +++ b/common/src/utils/merge-service-view.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import type { ServiceConfig } from '../models/service-config.js' +import type { ServiceDefinition } from '../models/service-definition.js' +import type { ServiceGitStatus } from '../models/service-git-status.js' +import type { ServiceStatus } from '../models/service-status.js' +import { mergeServiceView } from './merge-service-view.js' + +const baseDef: ServiceDefinition = { + id: 'svc-1', + stackName: 'stack-1', + displayName: 'Test Service', + description: 'A test service', + runCommand: 'npm start', + files: [], + createdAt: '2024-01-01', + updatedAt: '2024-01-01', +} + +describe('mergeServiceView', () => { + it('should produce a ServiceView with defaults when only definition is provided', () => { + const result = mergeServiceView(baseDef) + expect(result.serviceId).toBe('svc-1') + expect(result.displayName).toBe('Test Service') + expect(result.runCommand).toBe('npm start') + expect(result.autoFetchEnabled).toBe(false) + expect(result.autoFetchIntervalMinutes).toBe(60) + expect(result.autoRestartOnFetch).toBe(false) + expect(result.environmentVariableOverrides).toEqual({}) + expect(result.localFiles).toEqual([]) + expect(result.cloneStatus).toBe('not-cloned') + expect(result.installStatus).toBe('not-installed') + expect(result.buildStatus).toBe('not-built') + expect(result.runStatus).toBe('stopped') + }) + + it('should merge config values over defaults', () => { + const config: ServiceConfig = { + serviceId: 'svc-1', + autoFetchEnabled: true, + autoFetchIntervalMinutes: 30, + autoRestartOnFetch: true, + environmentVariableOverrides: { KEY: { source: 'custom', customValue: 'val' } }, + localFiles: [{ relativePath: 'config.json', content: '{}' }], + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + } + const result = mergeServiceView(baseDef, config) + expect(result.autoFetchEnabled).toBe(true) + expect(result.autoFetchIntervalMinutes).toBe(30) + expect(result.autoRestartOnFetch).toBe(true) + expect(result.environmentVariableOverrides).toEqual({ KEY: { source: 'custom', customValue: 'val' } }) + expect(result.localFiles).toHaveLength(1) + }) + + it('should merge status values over defaults', () => { + const status: ServiceStatus = { + serviceId: 'svc-1', + cloneStatus: 'cloned', + installStatus: 'installed', + buildStatus: 'built', + runStatus: 'running', + updatedAt: '2024-01-01', + } + const result = mergeServiceView(baseDef, undefined, status) + expect(result.cloneStatus).toBe('cloned') + expect(result.installStatus).toBe('installed') + expect(result.buildStatus).toBe('built') + expect(result.runStatus).toBe('running') + }) + + it('should merge git status values', () => { + const gitStatus: ServiceGitStatus = { + serviceId: 'svc-1', + currentBranch: 'main', + commitsBehind: 3, + } + const result = mergeServiceView(baseDef, undefined, undefined, gitStatus) + expect(result.currentBranch).toBe('main') + expect(result.commitsBehind).toBe(3) + }) + + it('should merge all sources together', () => { + const config: ServiceConfig = { + serviceId: 'svc-1', + autoFetchEnabled: true, + autoFetchIntervalMinutes: 15, + autoRestartOnFetch: false, + environmentVariableOverrides: {}, + localFiles: [], + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + } + const status: ServiceStatus = { + serviceId: 'svc-1', + cloneStatus: 'cloned', + installStatus: 'installed', + buildStatus: 'built', + runStatus: 'running', + updatedAt: '2024-01-01', + } + const gitStatus: ServiceGitStatus = { + serviceId: 'svc-1', + currentBranch: 'develop', + commitsBehind: 0, + } + const result = mergeServiceView(baseDef, config, status, gitStatus) + expect(result.serviceId).toBe('svc-1') + expect(result.autoFetchEnabled).toBe(true) + expect(result.runStatus).toBe('running') + expect(result.currentBranch).toBe('develop') + }) + + it('should handle undefined optional parameters gracefully', () => { + const result = mergeServiceView(baseDef, undefined, undefined, undefined) + expect(result.serviceId).toBe('svc-1') + expect(result.runStatus).toBe('stopped') + }) +}) diff --git a/common/src/utils/merge-service-view.ts b/common/src/utils/merge-service-view.ts new file mode 100644 index 0000000..3b53de5 --- /dev/null +++ b/common/src/utils/merge-service-view.ts @@ -0,0 +1,36 @@ +import type { ServiceConfig } from '../models/service-config.js' +import type { ServiceDefinition } from '../models/service-definition.js' +import type { ServiceGitStatus } from '../models/service-git-status.js' +import type { ServiceStatus } from '../models/service-status.js' +import type { ServiceRelations, ServiceView } from '../models/views.js' + +/** + * Merges separate service entities into a single flat view for API responses. + * Spread order: defaults β†’ definition β†’ config β†’ status β†’ gitStatus β†’ relations. + * Overlapping fields (e.g. createdAt, updatedAt) are won by later spreads. + */ +export const mergeServiceView = ( + def: ServiceDefinition, + config?: ServiceConfig, + status?: ServiceStatus, + gitStatus?: ServiceGitStatus, + relations?: ServiceRelations, +): ServiceView => ({ + serviceId: def.id, + autoFetchEnabled: false, + autoFetchIntervalMinutes: 60, + autoRestartOnFetch: false, + environmentVariableOverrides: {}, + localFiles: [], + cloneStatus: 'not-cloned', + installStatus: 'not-installed', + buildStatus: 'not-built', + runStatus: 'stopped', + prerequisiteIds: [], + prerequisiteServiceIds: [], + ...def, + ...(config ?? {}), + ...(status ?? {}), + ...(gitStatus ?? {}), + ...(relations ?? {}), +}) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..c2cfa5d --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,274 @@ +# Troubleshooting Guide + +## Common Startup Issues + +### Database Connection Failed + +**Symptom:** The service exits with a PostgreSQL connection error on startup. + +**Causes:** + +- PostgreSQL is not running +- `DATABASE_URL` is missing or incorrect in the `.env` file +- The database or user does not exist + +**Resolution:** + +1. Ensure PostgreSQL is running: `docker compose up -d` +2. Verify the connection string in `.env` matches your PostgreSQL setup +3. Default connection string: `postgres://stackcraft:stackcraft@localhost:5433/stackcraft` + +### Port Already in Use + +**Symptom:** `EADDRINUSE` error on startup. + +**Causes:** + +- Another instance of the service or another application is using port 9090 (or 8080 for frontend) + +**Resolution:** + +1. Stop the other process using the port +2. Or set a different port via the `APP_SERVICE_PORT` environment variable in `.env` + +### Missing Environment File + +**Symptom:** Service starts but cannot connect to the database. + +**Resolution:** + +1. Copy the example file: `cp .env.example .env` +2. Adjust values if your PostgreSQL setup differs from defaults + +--- + +## Service Management Issues + +### Service Stuck in "Starting" or "Installing" State + +**Symptom:** A managed service shows a stale status that never resolves. + +**Cause:** The backend was restarted while a service operation was in progress. The state was persisted but the process no longer exists. + +**Resolution:** On startup, Stack Craft automatically reconciles stale states. Restart the Stack Craft backend and the stuck service should return to a normal state. + +### Clone/Pull Fails + +**Symptom:** Cloning or pulling a repository fails with a git error. + +**Common causes:** + +- The repository URL is invalid or inaccessible +- Git is not installed on the system +- Authentication is required but not configured +- The target directory has conflicting content + +**Resolution:** + +1. Verify the repository URL in the repository settings +2. Ensure `git` is available in the system `PATH` +3. For private repositories, ensure SSH keys or credentials are configured +4. Check the service logs for the specific git error message + +### Prerequisite Check Fails + +**Symptom:** A prerequisite shows as "not satisfied" even though the tool is installed. + +**Common causes:** + +- The command runs in a restricted environment without access to the full `PATH` +- The expected version pattern does not match the installed version + +**Resolution:** + +1. Check the prerequisite configuration (command, expected output pattern) +2. Verify the tool is accessible from a clean shell (not just your user profile) +3. Review the check output in the prerequisite details for the actual command output + +--- + +## Viewing Logs + +### Service Logs (Managed Services) + +Service stdout/stderr is captured in-memory and available via: + +- **UI:** Navigate to the service detail page and click the "Logs" tab +- **API:** `GET /api/services/:id/logs?lines=300&search=error` +- **MCP:** Use the `get_service_logs` tool + +**Note:** Service logs are stored in-memory with a limit of 50,000 entries per service. Logs are lost when the Stack Craft backend restarts. + +### Application Logs (Stack Craft Backend) + +The backend logs to stdout via the FuryStack `VerboseConsoleLogger`. Log verbosity can be configured via the `LOG_LEVEL` environment variable (default: `verbose`). Available levels: `verbose`, `debug`, `information`, `warning`, `error`, `fatal`. In production, pipe stdout to your preferred log aggregator: + +```bash +# Docker +docker logs + +# systemd +journalctl -u stack-craft + +# File redirect +yarn start:service >> /var/log/stack-craft.log 2>&1 +``` + +### Health Check + +Use the health endpoint to verify the service is running and the database is connected: + +```bash +curl http://localhost:9090/api/system/health +# {"status":"ok","uptime":12345,"version":"1.0.3","database":"connected"} +``` + +--- + +## Environment Variables Reference + +| Variable | Default | Description | +| ---------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | `postgres://stackcraft:stackcraft@localhost:5433/stackcraft` | PostgreSQL connection string | +| `APP_SERVICE_PORT` | `9090` | Backend HTTP server port | +| `MCP_PORT` | `9091` | MCP server port | +| `STACK_CRAFT_ENCRYPTION_KEY` | _(auto-generated)_ | Base64-encoded 256-bit key for encrypting sensitive values. If unset, a key file is created in `~/.stack-craft/` | + +--- + +## MCP Server Troubleshooting + +### Connection Refused on Port 9091 + +**Symptom:** `curl: (7) Failed to connect to localhost port 9091: Connection refused` + +**Causes:** + +- The Stack Craft service is not running +- The MCP server is configured on a different port + +**Resolution:** + +1. Verify the service is running: `curl http://localhost:9090/api/system/health` +2. Check the `MCP_PORT` value in your `.env` file (default: `9091`) +3. Ensure nothing else is occupying the configured port + +### Authentication Errors + +**Symptom:** MCP tool calls return `401 Unauthorized` or `403 Forbidden`. + +**Cause:** The MCP server requires a Bearer token for authentication. + +**Resolution:** + +1. Open the Stack Craft UI and navigate to **User Settings** +2. Create a new API token +3. Configure your MCP client with the token as a Bearer token in the `Authorization` header + +### Common Tool Errors + +**Symptom:** MCP tool calls return "Service not found" or "Stack not found". + +**Causes:** + +- The service or stack ID/name is incorrect +- The resource was deleted or has not been imported yet + +**Resolution:** + +1. Use the `list_services` or `list_stacks` MCP tool to verify available resources +2. Double-check the ID or name passed to the tool +3. Import the stack or create the service if it does not exist yet + +### Testing the MCP Endpoint + +You can verify the MCP server is listening with: + +```bash +curl http://localhost:9091/mcp +# Expects 405 Method Not Allowed β€” the endpoint requires POST with SSE +``` + +A `405` response confirms the MCP server is running and reachable. Any other response (connection refused, timeout) indicates the server is not available. + +--- + +## Encryption Key Issues + +### Encryption Key Changed or Lost + +**Symptom:** Services that previously stored encrypted values (e.g. environment variable secrets) show decryption errors or garbled values. + +**Cause:** The `STACK_CRAFT_ENCRYPTION_KEY` has changed since the values were encrypted. Existing encrypted values become undecryptable with a different key. + +**Resolution:** + +1. If you still have the old key, restore it in `.env` and restart the service +2. If the old key is lost, you must re-enter all sensitive values (environment secrets, tokens, etc.) through the UI or API + +### Rotating the Encryption Key + +To rotate the encryption key: + +1. Stop the Stack Craft service +2. Update `STACK_CRAFT_ENCRYPTION_KEY` in your `.env` file with a new base64-encoded 256-bit key +3. Start the service +4. Re-enter all sensitive values (environment secrets, tokens) β€” they must be re-encrypted with the new key + +### Key File Location + +If the `STACK_CRAFT_ENCRYPTION_KEY` environment variable is not set, Stack Craft auto-generates a key file at: + +``` +~/.stack-craft/encryption.key +``` + +This file is created on first startup and reused on subsequent runs. Back up this file to avoid losing access to encrypted values. + +### Docker Persistence + +**Symptom:** Encrypted values break after a container restart. + +**Cause:** The auto-generated key file inside the container is lost when the container is recreated. + +**Resolution:** + +- **Option A:** Set the `STACK_CRAFT_ENCRYPTION_KEY` environment variable explicitly +- **Option B:** Mount the `~/.stack-craft/` directory as a volume to persist the key file + +--- + +## Docker Production + +### Port Mapping + +Stack Craft exposes two ports that both need to be mapped: + +| Port | Protocol | Purpose | +| ------ | -------------- | --------------------- | +| `9090` | HTTP/WebSocket | REST API and frontend | +| `9091` | HTTP/SSE | MCP server | + +### Encryption Key + +In production, always set `STACK_CRAFT_ENCRYPTION_KEY` explicitly rather than relying on the auto-generated key file. Alternatively, mount `~/.stack-craft/` as a persistent volume. + +### Example Docker Run + +```bash +docker run -d \ + --name stack-craft \ + -p 9090:9090 \ + -p 9091:9091 \ + -e DATABASE_URL=postgres://stackcraft:stackcraft@db:5432/stackcraft \ + -e STACK_CRAFT_ENCRYPTION_KEY=your-base64-encoded-key \ + stack-craft:latest +``` + +--- + +## Getting Help + +- Check the service logs for detailed error messages +- Use the MCP server (`localhost:9091`) with an AI assistant for interactive troubleshooting +- File issues at https://github.com/furystack/stack-craft/issues diff --git a/e2e/dogfooding.spec.ts b/e2e/dogfooding.spec.ts index 973b7a3..f65992f 100644 --- a/e2e/dogfooding.spec.ts +++ b/e2e/dogfooding.spec.ts @@ -1,5 +1,18 @@ +import { randomBytes } from 'crypto' + import { expect, test } from '@playwright/test' -import { login } from './helpers.js' + +import { + addPrerequisite, + createStack, + deleteStack, + expectNotification, + fillServiceForm, + getAvailablePort, + login, + navigateToCreateService, + submitServiceForm, +} from './helpers/index.js' test('DOG FOODING TIME - Create a service that uses the StackCraft GitHub repository. Clone, install, build and run the service', async ({ page, @@ -8,104 +21,185 @@ test('DOG FOODING TIME - Create a service that uses the StackCraft GitHub reposi const uuid = crypto.randomUUID() const stackName = `e2e-dog-fooding-time-${uuid}` - const displayName = `E2E DOG FOODING Stack - ${browserName} - ${uuid}` + const displayName = `Dog Fooding - ${browserName} - ${uuid}` const description = ` -### 🐢🦴 E2E - IT'S DOG FOODING TIME 🐢🦴 +##### 🐢🦴 E2E - IT'S DOG FOODING TIME 🐢🦴 This stack is used to test the dogfooding of the StackCraft application. It is used to test the following features: - Creating a stack + - Adding realistic prerequisites (Node.js, Yarn, Git, environment variables) + - Configuring environment variables (plain text + confidential) + - Adding a local .env file override - Creating a service that uses the StackCraft GitHub repository - Cloning, installing, building and running the service -### Test Steps +##### Test Steps 1. Create a stack - 2. Create a service that uses the StackCraft GitHub repository + 2. Create a service with prerequisites, env vars, file overrides, and the StackCraft GitHub repository 3. Clone, install, build and run the service - 4. Verify that the service is running - 5. Verify that the service is logging to the console - 6. Verify that the service is accessible via the browser - 7. Verify that the service is accessible via the API - 8. Remove the service - 9. Remove the stack - 10. Verify that the stack and service are removed - 11. The dog has eaten the food. Woof woof! + 4. Verify that the first start fails (port already in use) + 5. Override APP_SERVICE_PORT to a unique port and restart + 6. Verify that the service is running and reachable via HTTP + 7. Stop the service and remove the stack ` + const dogfoodingPort = await getAvailablePort() + const mcpPort = await getAvailablePort() const workingDirectory = `/tmp/e2e-dog-fooding-time-${uuid}` await page.goto('/') await login(page) - // Create stack - await page.locator('button, a', { hasText: 'Create Stack' }).first().click() - await expect(page.locator('shade-create-stack')).toBeVisible() - - await page.locator('input[name="name"]').fill(stackName) - await page.locator('input[name="displayName"]').fill(displayName) - await page.locator('textarea[name="description"]').fill(description) - await page.locator('input[name="mainDirectory"]').fill('/tmp/e2e-test') - await page.locator('button', { hasText: 'Create' }).click() - - await expect(page.locator('shade-noty-list')).toContainText(`Stack "${displayName}" was created successfully.`) - - await expect(page.locator('shade-dashboard')).toBeVisible() - - await expect(page.getByTestId('page-header-title')).toContainText(displayName) - - // Navigate to services list via the dashboard card - await page.locator('shade-dashboard a', { hasText: 'Services' }).click() - await expect(page.locator('shade-services-list')).toBeVisible() - await page.locator('button', { hasText: 'Create Service' }).first().click() - await expect(page.locator('shade-create-service-wizard')).toBeVisible() - - await page.locator('input[name="displayName"]').fill('StackCraft DOG FOODING TIME!') - await page.locator('input[name="workingDirectory"]').fill(workingDirectory) - await page.locator('input[name="runCommand"]').fill('yarn start:service') - - // Add StackCraft GitHub repository inline - await page.locator('button', { hasText: 'New' }).click() - const repoForm = page.locator('shade-github-repo-form') - await expect(repoForm).toBeVisible() - await repoForm.locator('input[name="url"]').fill('https://github.com/furystack/stack-craft') - await repoForm.locator('input[name="displayName"]').fill('StackCraft') - await repoForm.locator('button', { hasText: 'Add' }).click() - await expect(page.locator('shade-noty-list')).toContainText('"StackCraft" was added.') - - // Select the newly created repository - const repoSelect = page.locator('shade-select').filter({ has: page.locator('input[name="repositoryId"]') }) - await repoSelect.locator('.select-trigger').click() - await repoSelect.locator('.dropdown-item', { hasText: 'StackCraft' }).click() - - await page.locator('input[name="installCommand"]').fill('yarn install') - await page.locator('input[name="buildCommand"]').fill('yarn build') - await page.locator('input[name="runCommand"]').fill('yarn start:service') - - await page.locator('button', { hasText: 'Create' }).click() - - // Step 2: Set up the service (clone, install, build) - await page.locator('button', { hasText: 'Set up now' }).click() - - // Clone + install + build may take several minutes - await expect(page.getByText('Service set up successfully!')).toBeVisible({ timeout: 10 * 60 * 1000 }) - - // Navigate to the service detail - await page.locator('button', { hasText: 'View Service' }).click() - await expect(page.locator('shade-service-detail')).toBeVisible() - - // Start the service (click the primary action in the header, not the stepper) - await page.getByTestId('page-header-actions').getByRole('button', { name: 'Start' }).click() - - // Wait for the service to reach running state - await expect(page.locator('shade-service-status-indicator')).toContainText('Running') - - // Navigate to the Logs tab via the tab bar - await page.getByTestId('service-detail-tabs').getByRole('button', { name: 'Logs' }).click() - await expect(page.locator('shade-service-logs-tab')).toBeVisible() - - // Verify that the log viewer is present with entries - await expect(page.locator('shade-service-logs-tab shade-log-viewer')).toBeVisible() - await expect(page.locator('shade-service-logs-tab shade-log-viewer')).not.toContainText('No log output yet.') + try { + await test.step('Create stack', async () => { + await createStack(page, { + name: stackName, + displayName, + description, + mainDirectory: '/tmp/e2e-test', + }) + }) + + await test.step('Configure and create service', async () => { + await navigateToCreateService(page, displayName) + + await fillServiceForm(page, { + displayName: 'StackCraft DOG FOODING TIME!', + workingDirectory, + runCommand: 'yarn start:service', + }) + + // --- Add realistic prerequisites --- + await addPrerequisite(page, { name: 'Node.js >= 22', type: 'Node.js', minimumVersion: '22.0.0' }) + await addPrerequisite(page, { name: 'Yarn >= 4', type: 'Yarn', minimumVersion: '4.0.0' }) + await addPrerequisite(page, { name: 'Git', type: 'Git' }) + await addPrerequisite(page, { name: 'Mock API Key', type: 'Environment Variable', variableName: 'MOCK_API_KEY' }) + await addPrerequisite(page, { + name: 'Encryption Key', + type: 'Environment Variable', + variableName: 'STACK_CRAFT_ENCRYPTION_KEY', + isSensitive: true, + }) + await addPrerequisite(page, { name: 'Database URL', type: 'Environment Variable', variableName: 'DATABASE_URL' }) + await addPrerequisite(page, { + name: 'Service Port', + type: 'Environment Variable', + variableName: 'APP_SERVICE_PORT', + }) + await addPrerequisite(page, { name: 'MCP Port', type: 'Environment Variable', variableName: 'MCP_PORT' }) + + // --- Add local .env file override --- + await page.locator('button', { hasText: 'Add Local File' }).click() + await page.locator('input[placeholder="Relative path (e.g. .env)"]').fill('.env') + await page + .locator('textarea[placeholder="File content"]') + .fill(`STACK_CRAFT_ENCRYPTION_KEY=${randomBytes(32).toString('base64')}`) + + // --- Add StackCraft GitHub repository inline --- + await page.locator('button', { hasText: 'New' }).click() + const repoForm = page.locator('shade-github-repo-form') + await expect(repoForm).toBeVisible() + await repoForm.locator('input[name="url"]').fill('https://github.com/furystack/stack-craft') + await repoForm.locator('input[name="displayName"]').fill('StackCraft') + await repoForm.locator('button', { hasText: 'Add' }).click() + await expectNotification(page, '"StackCraft" was added.') + + // Select the newly created repository + const repoSelect = page.locator('shade-select').filter({ has: page.locator('input[name="repositoryId"]') }) + await repoSelect.locator('.select-trigger').click() + await repoSelect.locator('.dropdown-item', { hasText: 'StackCraft' }).click() + + await page.locator('input[name="installCommand"]').fill('yarn install') + await page.locator('input[name="buildCommand"]').fill('yarn build') + await page.locator('input[name="runCommand"]').fill('yarn start:service') + + await submitServiceForm(page) + }) + + await test.step('Set up service (clone, install, build)', async () => { + await page.locator('button', { hasText: 'Set up now' }).click() + + // Clone + install + build may take several minutes + await expect(page.getByText('Service set up successfully!')).toBeVisible({ timeout: 10 * 60 * 1000 }) + + await page.locator('button', { hasText: 'View Service' }).click() + await expect(page.locator('shade-service-detail')).toBeVisible() + }) + + await test.step('First start attempt β€” expect failure (port already in use)', async () => { + await page.getByTestId('service-detail-action-bar').getByRole('button', { name: 'Start' }).click() + + // The spawned service will try to bind to port 9090 (default), which is + // already occupied by the test host. Expect status to transition to Error. + await expect(page.getByTestId('service-status-indicator')).toContainText('Error', { timeout: 60_000 }) + + await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Logs' }).click() + await expect(page.locator('shade-service-logs-tab')).toBeVisible() + + const logViewer = page.locator('shade-service-logs-tab shade-log-viewer') + await expect(logViewer).toBeVisible() + await expect(logViewer).toContainText(/EADDRINUSE|address already in use/, { timeout: 10_000 }) + }) + + await test.step('Override APP_SERVICE_PORT and MCP_PORT, then restart', async () => { + await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Configuration' }).click() + await expect(page.locator('shade-service-config-tab')).toBeVisible() + + // Set APP_SERVICE_PORT override to a unique port per browser + const portOverride = page.getByTestId('env-override-APP_SERVICE_PORT') + await expect(portOverride).toBeVisible() + + const portSourceSelect = portOverride.locator('shade-select').first() + await portSourceSelect.locator('.select-trigger').click() + await portSourceSelect.locator('.dropdown-item', { hasText: 'Custom value' }).click() + await portOverride.locator('shade-input input').fill(String(dogfoodingPort)) + + // Set MCP_PORT override to avoid conflict with the test host's MCP server + const mcpOverride = page.getByTestId('env-override-MCP_PORT') + await expect(mcpOverride).toBeVisible() + + const mcpSourceSelect = mcpOverride.locator('shade-select').first() + await mcpSourceSelect.locator('.select-trigger').click() + await mcpSourceSelect.locator('.dropdown-item', { hasText: 'Custom value' }).click() + await mcpOverride.locator('shade-input input').fill(String(mcpPort)) + + // Save the overrides and wait for the save to complete + const saveButton = page.getByTestId('save-env-overrides') + await saveButton.click() + await expect(saveButton).not.toHaveAttribute('loading', { timeout: 10_000 }) + + // Navigate back to the Overview tab and start the service again + await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Overview' }).click() + await page.getByTestId('service-detail-action-bar').getByRole('button', { name: 'Start' }).click() + + await expect(page.getByTestId('service-status-indicator')).toContainText('Running', { timeout: 60_000 }) + }) + + await test.step('Verify logs and HTTP reachability', async () => { + await page.getByTestId('service-detail-tabs').getByRole('tab', { name: 'Logs' }).click() + await expect(page.locator('shade-service-logs-tab')).toBeVisible() + + const successLogViewer = page.locator('shade-service-logs-tab shade-log-viewer') + await expect(successLogViewer).toBeVisible() + await expect(successLogViewer).not.toContainText('No log output yet.') + + await expect(async () => { + const response = await page.request.get(`http://localhost:${dogfoodingPort}`) + expect(response.status()).toBe(200) + }).toPass({ timeout: 60_000 }) + }) + + await test.step('Stop the service', async () => { + // SIGTERM may cause non-zero exit β†’ "Error" or clean β†’ "Stopped" + await page.getByTestId('service-detail-action-bar').getByRole('button', { name: 'Stop' }).click() + await expect(page.getByTestId('service-status-indicator')).toContainText(/Stopped|Error/, { timeout: 30_000 }) + }) + } finally { + await test.step('Clean up stack', async () => { + await deleteStack(page, displayName) + }) + } }) diff --git a/e2e/helpers/get-available-port.ts b/e2e/helpers/get-available-port.ts new file mode 100644 index 0000000..f273513 --- /dev/null +++ b/e2e/helpers/get-available-port.ts @@ -0,0 +1,12 @@ +import { createServer } from 'net' + +/** Binds to port 0 so the OS assigns a free port, then closes the server and returns the port number */ +export const getAvailablePort = (): Promise => + new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => { + const { port } = server.address() as { port: number } + server.close(() => resolve(port)) + }) + server.on('error', reject) + }) diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts new file mode 100644 index 0000000..0ab391a --- /dev/null +++ b/e2e/helpers/index.ts @@ -0,0 +1,8 @@ +export { getAvailablePort } from './get-available-port.js' +export { login } from './login.js' +export { expectNotification } from './notification.js' +export { addPrerequisite, type PrerequisiteParams } from './prerequisite.js' +export { addRepository, type AddRepositoryParams } from './repository.js' +export { fillServiceForm, navigateToCreateService, submitServiceForm, type CreateServiceParams } from './service.js' +export { navigateViaSidebar } from './sidebar.js' +export { createStack, deleteStack, type CreateStackParams } from './stack.js' diff --git a/e2e/helpers.ts b/e2e/helpers/login.ts similarity index 100% rename from e2e/helpers.ts rename to e2e/helpers/login.ts diff --git a/e2e/helpers/notification.ts b/e2e/helpers/notification.ts new file mode 100644 index 0000000..8902a63 --- /dev/null +++ b/e2e/helpers/notification.ts @@ -0,0 +1,6 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export const expectNotification = async (page: Page, text: string) => { + await expect(page.locator('shade-noty-list')).toContainText(text) +} diff --git a/e2e/helpers/prerequisite.ts b/e2e/helpers/prerequisite.ts new file mode 100644 index 0000000..38ed72b --- /dev/null +++ b/e2e/helpers/prerequisite.ts @@ -0,0 +1,36 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { expectNotification } from './notification.js' + +export type PrerequisiteParams = { + name: string + type: 'Node.js' | 'Yarn' | 'Git' | 'Environment Variable' + minimumVersion?: string + variableName?: string + isSensitive?: boolean +} + +export const addPrerequisite = async (page: Page, params: PrerequisiteParams) => { + const prereqForm = page.locator('shade-prerequisite-form') + const typeSelect = page.locator('shade-select').filter({ has: page.locator('input[name="type"]') }) + + await page.locator('button', { hasText: 'Add Prerequisite' }).click() + await expect(prereqForm).toBeVisible() + await prereqForm.locator('input[name="name"]').fill(params.name) + await typeSelect.locator('.select-trigger').click() + await typeSelect.locator('.dropdown-item', { hasText: params.type }).first().click() + + if (params.minimumVersion) { + await prereqForm.locator('input[name="minimumVersion"]').fill(params.minimumVersion) + } + if (params.variableName) { + await prereqForm.locator('input[name="variableName"]').fill(params.variableName) + } + if (params.isSensitive) { + await prereqForm.locator('input[name="isSensitive"]').check() + } + + await prereqForm.locator('button', { hasText: 'Add' }).click() + await expectNotification(page, `"${params.name}" was added.`) +} diff --git a/e2e/helpers/repository.ts b/e2e/helpers/repository.ts new file mode 100644 index 0000000..918bc8b --- /dev/null +++ b/e2e/helpers/repository.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { navigateViaSidebar } from './sidebar.js' + +export type AddRepositoryParams = { + displayName: string + url: string +} + +export const addRepository = async (page: Page, stackDisplayName: string, params: AddRepositoryParams) => { + await navigateViaSidebar(page, stackDisplayName, 'Repositories') + await expect(page.locator('shade-repositories-list')).toBeVisible() + + await page.locator('button', { hasText: 'Add Repository' }).first().click() + await expect(page.locator('shade-create-repository')).toBeVisible() + + await page.locator('input[name="displayName"]').fill(params.displayName) + await page.locator('input[name="url"]').fill(params.url) + await page.locator('button', { hasText: 'Add' }).click() + + await expect(page.locator('shade-repositories-list')).toBeVisible() +} diff --git a/e2e/helpers/service.ts b/e2e/helpers/service.ts new file mode 100644 index 0000000..380aaba --- /dev/null +++ b/e2e/helpers/service.ts @@ -0,0 +1,35 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { navigateViaSidebar } from './sidebar.js' + +export type CreateServiceParams = { + displayName: string + workingDirectory: string + runCommand: string + installCommand?: string + buildCommand?: string +} + +export const navigateToCreateService = async (page: Page, stackDisplayName: string) => { + await navigateViaSidebar(page, stackDisplayName, 'Services') + await expect(page.locator('shade-services-list')).toBeVisible() + await page.locator('button', { hasText: 'Create Service' }).first().click() + await expect(page.locator('shade-create-service-wizard')).toBeVisible() +} + +export const fillServiceForm = async (page: Page, params: CreateServiceParams) => { + await page.locator('input[name="displayName"]').fill(params.displayName) + await page.locator('input[name="workingDirectory"]').fill(params.workingDirectory) + await page.locator('input[name="runCommand"]').fill(params.runCommand) + if (params.installCommand) { + await page.locator('input[name="installCommand"]').fill(params.installCommand) + } + if (params.buildCommand) { + await page.locator('input[name="buildCommand"]').fill(params.buildCommand) + } +} + +export const submitServiceForm = async (page: Page) => { + await page.locator('button', { hasText: 'Create' }).click() +} diff --git a/e2e/helpers/sidebar.ts b/e2e/helpers/sidebar.ts new file mode 100644 index 0000000..83ce5b3 --- /dev/null +++ b/e2e/helpers/sidebar.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' + +export const navigateViaSidebar = async ( + page: Page, + stackDisplayName: string, + link: 'Overview' | 'Services' | 'Repositories' | 'Prerequisites', +) => { + const stackSidebar = page.locator('shade-accordion-item').filter({ hasText: stackDisplayName }) + + // Expand the accordion if it's collapsed + const isExpanded = await stackSidebar.getAttribute('data-expanded') + if (isExpanded === null) { + await stackSidebar.locator('.accordion-header').click() + } + + await stackSidebar.locator('shade-sidebar-stack-link a', { hasText: link }).click() +} diff --git a/e2e/helpers/stack.ts b/e2e/helpers/stack.ts new file mode 100644 index 0000000..6ac4a11 --- /dev/null +++ b/e2e/helpers/stack.ts @@ -0,0 +1,46 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { expectNotification } from './notification.js' + +export type CreateStackParams = { + name: string + displayName: string + description: string + mainDirectory: string +} + +export const createStack = async (page: Page, params: CreateStackParams) => { + await page.locator('button, a', { hasText: 'Create Stack' }).first().click() + await expect(page.locator('shade-create-stack')).toBeVisible() + + await page.locator('input[name="name"]').fill(params.name) + await page.locator('input[name="displayName"]').fill(params.displayName) + await page.locator('textarea[name="description"]').fill(params.description) + await page.locator('input[name="mainDirectory"]').fill(params.mainDirectory) + await page.locator('button', { hasText: 'Create' }).click() + + await expectNotification(page, `Stack "${params.displayName}" was created successfully.`) + await expect(page.locator('shade-dashboard')).toBeVisible() + await expect(page.getByTestId('page-header-title')).toContainText(params.displayName) +} + +export const deleteStack = async (page: Page, displayName: string) => { + // Navigate to the main dashboard via the sidebar "Dashboard" link (always visible, no accordion) + await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click() + await expect(page.locator('stack-list-dashboard')).toBeVisible() + + // Click on the stack card to open its overview + await page.locator('stack-list-dashboard shade-card', { hasText: displayName }).click() + await expect(page.getByTestId('page-header-title')).toContainText(displayName) + + // Navigate to Edit Stack, then delete + await page.locator('a', { hasText: 'Edit Stack' }).click() + await expect(page.locator('shade-edit-stack')).toBeVisible() + + await page.locator('button', { hasText: 'Delete Stack' }).click() + await page.locator('shade-dialog .dialog-confirm-btn').click() + + await expectNotification(page, `"${displayName}" was deleted.`) + await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10_000 }) +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 37ea60e..b40d9b8 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,78 +1,62 @@ import { expect, test } from '@playwright/test' -import { login } from './helpers.js' -test.describe.serial('App Flow', () => { - test('Login and view dashboard', async ({ page }) => { - await page.goto('/') - await login(page) - }) - - test('Create stack, create service, verify dashboard', async ({ page, browserName }) => { - const uuid = crypto.randomUUID() - - const stackName = `e2e-test-stack-${uuid}` - const displayName = `E2E Test Stack - ${browserName} - ${uuid}` - - const workingDirectory = `/tmp/e2e-test-stack-${uuid}` - - await page.goto('/') - await login(page) - - // Create stack - await page.locator('button, a', { hasText: 'Create Stack' }).first().click() - await expect(page.locator('shade-create-stack')).toBeVisible() - - await page.locator('input[name="name"]').fill(stackName) - await page.locator('input[name="displayName"]').fill(displayName) - await page.locator('textarea[name="description"]').fill('Created by E2E test') - await page.locator('input[name="mainDirectory"]').fill('/tmp/e2e-test') - await page.locator('button', { hasText: 'Create' }).click() - - await expect(page.locator('shade-noty-list')).toContainText(`Stack "${displayName}" was created successfully.`) - - await expect(page.locator('shade-dashboard')).toBeVisible() - - await expect(page.getByTestId('page-header-title')).toContainText(displayName) - - // Navigate to services list via the dashboard card - await page.locator('shade-dashboard a', { hasText: 'Services' }).click() - await expect(page.locator('shade-services-list')).toBeVisible() - await page.locator('button', { hasText: 'Create Service' }).first().click() - await expect(page.locator('shade-create-service-wizard')).toBeVisible() - - await page.locator('input[name="displayName"]').fill('E2E Service') - await page.locator('input[name="workingDirectory"]').fill(workingDirectory) - await page.locator('input[name="runCommand"]').fill('echo hello') - await page.locator('button', { hasText: 'Create' }).click() - - await expect(page.locator('shade-services-list')).toBeVisible() - - // Scope all sidebar interactions to the correct stack - const stackSidebar = page.locator('shade-sidebar-stack-category').filter({ hasText: displayName }) - - // Navigate to repositories list - await stackSidebar.locator('shade-sidebar-stack-link a', { hasText: 'Repositories' }).click() - await expect(page.locator('shade-repositories-list')).toBeVisible() - await page.locator('button', { hasText: 'Add Repository' }).first().click() - await expect(page.locator('shade-create-repository')).toBeVisible() - - await page.locator('input[name="displayName"]').fill('FuryStack') - await page.locator('input[name="url"]').fill('https://github.com/furystack/furystack') - await page.locator('button', { hasText: 'Add' }).click() - - await expect(page.locator('shade-repositories-list')).toBeVisible() - - // Navigate to services list - await stackSidebar.locator('shade-sidebar-stack-link a', { hasText: 'Services' }).click() - await expect(page.locator('shade-services-list')).toBeVisible() - await page.locator('button', { hasText: 'Create Service' }).first().click() - await expect(page.locator('shade-create-service-wizard')).toBeVisible() - - await page.locator('input[name="displayName"]').fill('StackCraft DOG FOODING TIME!') - await page.locator('input[name="workingDirectory"]').fill(workingDirectory) - await page.locator('input[name="runCommand"]').fill('echo hello') - await page.locator('button', { hasText: 'Create' }).click() - - await expect(page.locator('shade-services-list')).toBeVisible() - }) +import { + addRepository, + createStack, + deleteStack, + fillServiceForm, + login, + navigateToCreateService, + submitServiceForm, +} from './helpers/index.js' + +test('App Flow', async ({ page, browserName }) => { + const uuid = crypto.randomUUID() + + const stackName = `e2e-test-stack-${uuid}` + const displayName = `E2E Test Stack - ${browserName} - ${uuid}` + const workingDirectory = `/tmp/e2e-test-stack-${uuid}` + + await page.goto('/') + await login(page) + + try { + await test.step('Create stack', async () => { + await createStack(page, { + name: stackName, + displayName, + description: 'Created by E2E test', + mainDirectory: '/tmp/e2e-test', + }) + }) + + await test.step('Create first service', async () => { + await navigateToCreateService(page, displayName) + await fillServiceForm(page, { displayName: 'E2E Service', workingDirectory, runCommand: 'echo hello' }) + await submitServiceForm(page) + await expect(page.locator('shade-services-list')).toBeVisible() + }) + + await test.step('Add repository', async () => { + await addRepository(page, displayName, { + displayName: 'FuryStack', + url: 'https://github.com/furystack/furystack', + }) + }) + + await test.step('Create second service', async () => { + await navigateToCreateService(page, displayName) + await fillServiceForm(page, { + displayName: 'StackCraft DOG FOODING TIME!', + workingDirectory, + runCommand: 'echo hello', + }) + await submitServiceForm(page) + await expect(page.locator('shade-services-list')).toBeVisible() + }) + } finally { + await test.step('Clean up stack', async () => { + await deleteStack(page, displayName) + }) + } }) diff --git a/frontend/README.md b/frontend/README.md index 4256a14..2d92cd1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,26 +1,47 @@ -# Introduction +# Stack Craft Frontend -TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. +Shades-based single page application for Stack Craft. -# Getting Started +## Tech Stack -TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: +- **[Shades](https://github.com/furystack/furystack/tree/develop/packages/shades)** -- FuryStack's web component framework +- **[Vite](https://vite.dev/)** -- Build tool and dev server +- **TypeScript** -- Strict mode enabled -1. Installation process -2. Software dependencies -3. Latest releases -4. API references +## Development -# Build and Test +```bash +# Start the dev server (port 8080) +yarn start -TODO: Describe and show how to build your code and run the tests. +# Build for production +yarn build +``` -# Contribute +The dev server proxies API requests to the backend on port 9090. Make sure the backend is running before starting the frontend. -TODO: Explain how other users and developers can contribute to make your code better. +## Project Structure -If you want to learn more about creating good readme files then refer the following [guidelines](https://www.visualstudio.com/en-us/docs/git/create-a-readme). You can also seek inspiration from the below readme files: +``` +frontend/src/ +β”œβ”€β”€ components/ # Reusable UI components +β”‚ β”œβ”€β”€ entity-forms/ # Form components for creating/editing entities +β”‚ └── ... +β”œβ”€β”€ pages/ # Page-level components (one per route) +β”‚ β”œβ”€β”€ dashboard/ +β”‚ β”œβ”€β”€ services/ +β”‚ β”œβ”€β”€ stacks/ +β”‚ β”œβ”€β”€ wizards/ +β”‚ └── ... +β”œβ”€β”€ services/ # Business logic and API client services +β”‚ └── api-clients/ # Typed REST API clients +β”œβ”€β”€ utils/ # Utility functions +└── index.tsx # Application entry point +``` -- [ASP.NET Core](https://github.com/aspnet/Home) -- [Visual Studio Code](https://github.com/Microsoft/vscode) -- [Chakra Core](https://github.com/Microsoft/ChakraCore) +## Key Patterns + +- **Dependency injection:** Services are accessed via `injector.getInstance(ServiceClass)` +- **Reactive state:** `useObservable` subscribes to `ObservableValue` instances from services +- **Entity sync:** Real-time data updates via WebSocket through `@furystack/entity-sync-client` +- **Routing:** `NestedRouter` with typed routes defined in `components/app-routes.tsx` diff --git a/frontend/package.json b/frontend/package.json index 573d977..6a77014 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,8 @@ "@furystack/logging": "^8.1.4", "@furystack/rest-client-fetch": "^8.1.6", "@furystack/shades": "^13.2.1", - "@furystack/shades-common-components": "^15.0.4", + "@furystack/shades-common-components": "^15.1.0", + "@furystack/shades-mfe": "^3.0.5", "@furystack/utils": "^8.2.4", "common": "workspace:^" } diff --git a/frontend/public/style.css b/frontend/public/style.css index b56d5fe..fdcb14a 100644 --- a/frontend/public/style.css +++ b/frontend/public/style.css @@ -3,24 +3,24 @@ head { margin: 0; padding: 0; font-family: 'Franklin Gothic Medium', 'Arial Narrow', Arial, sans-serif; + scrollbar-width: thin; + scrollbar-color: rgba(128, 128, 128, 0.25) transparent; } -/* width */ ::-webkit-scrollbar { - width: 10px; + width: 6px; + height: 6px; } -/* Track */ ::-webkit-scrollbar-track { - background: rgba(128, 128, 128, 0.3); + background: transparent; } -/* Handle */ ::-webkit-scrollbar-thumb { - background: #888; + background: rgba(128, 128, 128, 0.25); + border-radius: 3px; } -/* Handle on hover */ ::-webkit-scrollbar-thumb:hover { - background: #555; + background: rgba(128, 128, 128, 0.5); } diff --git a/frontend/src/components/app-routes.tsx b/frontend/src/components/app-routes.tsx index 8e568d7..dbd901f 100644 --- a/frontend/src/components/app-routes.tsx +++ b/frontend/src/components/app-routes.tsx @@ -7,15 +7,14 @@ import { PrerequisitesList } from '../pages/prerequisites/prerequisites-list.js' import { CreateRepository } from '../pages/repositories/create-repository.js' import { EditRepository } from '../pages/repositories/edit-repository.js' import { RepositoriesList } from '../pages/repositories/repositories-list.js' -import { CreateService } from '../pages/services/create-service.js' -import { ServiceDetail } from '../pages/services/service-detail.js' +import { ServiceDetail } from '../pages/services/service-detail/index.js' import { ServiceLogs } from '../pages/services/service-logs.js' import { ServicesList } from '../pages/services/services-list.js' import { UserSettings } from '../pages/settings/user-settings.js' import { CreateStack } from '../pages/stacks/create-stack.js' import { EditStack } from '../pages/stacks/edit-stack.js' import { StackSetup } from '../pages/stacks/stack-setup.js' -import { CreateServiceWizard } from '../pages/wizards/create-service-wizard.js' +import { CreateServiceWizard } from '../pages/wizards/create-service-wizard/index.js' export const appRoutes = { '/': { @@ -55,11 +54,6 @@ export const appRoutes = { ), }, - '/stacks/:stackName/services/create': { - component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - - ), - }, '/stacks/:stackName/services/wizard': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( @@ -104,7 +98,7 @@ export const appRoutes = { ), }, -} as const satisfies Record> +} as const satisfies Record> // NestedRouterProps requires `any` for heterogeneous route params export const StackCraftNestedRouteLink = createNestedRouteLink() diff --git a/frontend/src/components/branch-selector.tsx b/frontend/src/components/branch-selector.tsx index 1b75bdd..f8ecd59 100644 --- a/frontend/src/components/branch-selector.tsx +++ b/frontend/src/components/branch-selector.tsx @@ -1,5 +1,5 @@ import { createComponent, Shade } from '@furystack/shades' -import { Button, Chip, cssVariableTheme, Icon, icons, NotyService } from '@furystack/shades-common-components' +import { Chip, NotyService } from '@furystack/shades-common-components' import { ServicesApiClient } from '../services/api-clients/services-api-client.js' @@ -9,30 +9,201 @@ type BranchSelectorProps = { isCloned: boolean } +/** + * Builds the dropdown DOM on document.body so it never affects table/scroll layout. + * Returns a cleanup function that removes all created elements. + */ +const showBranchDropdown = (opts: { + anchor: HTMLElement + branches: { local: string[]; remote: string[] } + currentBranch: string | undefined + onSelect: (branch: string) => void +}): Disposable => { + const backdrop = document.createElement('div') + Object.assign(backdrop.style, { + position: 'fixed', + inset: '0', + zIndex: '9999', + }) + + const dropdown = document.createElement('div') + Object.assign(dropdown.style, { + position: 'fixed', + zIndex: '10000', + width: '260px', + maxHeight: '280px', + display: 'flex', + flexDirection: 'column', + background: 'var(--shades-theme-background-paper)', + border: '1px solid var(--shades-theme-action-subtleBorder)', + borderRadius: 'var(--shades-theme-shape-borderRadius-md)', + boxShadow: 'var(--shades-theme-shadows-lg)', + overflow: 'hidden', + fontFamily: 'inherit', + }) + + const rect = opts.anchor.getBoundingClientRect() + const spaceBelow = window.innerHeight - rect.bottom + const top = spaceBelow >= 280 ? rect.bottom + 4 : rect.top - 280 - 4 + dropdown.style.top = `${Math.max(4, top)}px` + dropdown.style.left = `${rect.left}px` + + const searchInput = document.createElement('input') + Object.assign(searchInput.style, { + padding: '8px 10px', + border: 'none', + borderBottom: '1px solid var(--shades-theme-action-subtleBorder)', + background: 'transparent', + color: 'var(--shades-theme-text-primary)', + fontSize: 'var(--shades-theme-typography-fontSize-sm)', + fontFamily: 'inherit', + outline: 'none', + boxSizing: 'border-box', + }) + searchInput.type = 'text' + searchInput.placeholder = 'Filter branches...' + + const listContainer = document.createElement('div') + Object.assign(listContainer.style, { overflowY: 'auto', flex: '1' }) + + const cleanup = () => { + backdrop.remove() + dropdown.remove() + } + + const createItem = (branch: string) => { + const btn = document.createElement('button') + btn.type = 'button' + btn.textContent = branch + Object.assign(btn.style, { + display: 'block', + width: '100%', + padding: '5px 10px', + border: 'none', + background: 'transparent', + color: + branch === opts.currentBranch ? 'var(--shades-theme-palette-primary-main)' : 'var(--shades-theme-text-primary)', + fontWeight: branch === opts.currentBranch ? '600' : 'normal', + fontSize: 'var(--shades-theme-typography-fontSize-sm)', + fontFamily: 'inherit', + textAlign: 'left', + cursor: 'pointer', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + boxSizing: 'border-box', + }) + btn.addEventListener('mouseenter', () => { + btn.style.background = 'var(--shades-theme-action-hoverBackground)' + }) + btn.addEventListener('mouseleave', () => { + btn.style.background = 'transparent' + }) + btn.addEventListener('click', () => { + cleanup() + opts.onSelect(branch) + }) + return btn + } + + const createGroupLabel = (text: string) => { + const div = document.createElement('div') + div.textContent = text + Object.assign(div.style, { + padding: '6px 10px 2px', + fontSize: 'var(--shades-theme-typography-fontSize-xs)', + fontWeight: '600', + color: 'var(--shades-theme-text-secondary)', + textTransform: 'uppercase', + letterSpacing: '0.04em', + }) + return div + } + + const renderList = (filterText: string) => { + listContainer.innerHTML = '' + const lower = filterText.toLowerCase() + const localFiltered = opts.branches.local.filter((b) => b.toLowerCase().includes(lower)) + const remoteFiltered = opts.branches.remote.filter((b) => !b.endsWith('/HEAD') && b.toLowerCase().includes(lower)) + + if (localFiltered.length > 0) { + listContainer.appendChild(createGroupLabel('Local')) + for (const b of localFiltered) listContainer.appendChild(createItem(b)) + } + if (remoteFiltered.length > 0) { + listContainer.appendChild(createGroupLabel('Remote')) + for (const b of remoteFiltered) listContainer.appendChild(createItem(b)) + } + if (localFiltered.length === 0 && remoteFiltered.length === 0) { + const empty = document.createElement('div') + empty.textContent = 'No matching branches' + Object.assign(empty.style, { padding: '12px', textAlign: 'center', opacity: '0.5', fontSize: '13px' }) + listContainer.appendChild(empty) + } + } + + searchInput.addEventListener('input', () => renderList(searchInput.value)) + backdrop.addEventListener('click', cleanup) + + dropdown.appendChild(searchInput) + dropdown.appendChild(listContainer) + document.body.appendChild(backdrop) + document.body.appendChild(dropdown) + + renderList('') + searchInput.focus() + + return { [Symbol.dispose]: cleanup } +} + export const BranchSelector = Shade({ customElementName: 'shade-branch-selector', - render: ({ props, injector, useState }) => { + css: { + display: 'inline-block', + + '& .branch-trigger': { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '3px 10px', + border: '1px solid var(--shades-theme-action-subtleBorder)', + borderRadius: 'var(--shades-theme-shape-borderRadius-md)', + background: 'transparent', + color: 'var(--shades-theme-text-primary)', + fontSize: 'var(--shades-theme-typography-fontSize-sm)', + fontFamily: 'inherit', + cursor: 'pointer', + whiteSpace: 'nowrap', + maxWidth: '180px', + overflow: 'hidden', + textOverflow: 'ellipsis', + transition: 'border-color 0.15s ease', + }, + '& .branch-trigger:hover': { + borderColor: 'var(--shades-theme-palette-primary-main)', + }, + '& .branch-trigger[data-disabled]': { + opacity: '0.5', + cursor: 'wait', + }, + '& .branch-arrow': { + fontSize: '8px', + opacity: '0.5', + flexShrink: '0', + }, + }, + render: ({ props, injector, useState, useDisposable, useRef }) => { + const triggerRef = useRef('triggerRef') const { serviceId, currentBranch, isCloned } = props - const [isOpen, setIsOpen] = useState('isOpen', false) const [branches, setBranches] = useState<{ local: string[]; remote: string[] } | null>('branches', null) const [isLoading, setIsLoading] = useState('isLoading', false) - const [filter, setFilter] = useState('filter', '') const [isCheckingOut, setIsCheckingOut] = useState('isCheckingOut', false) const api = injector.getInstance(ServicesApiClient) const noty = injector.getInstance(NotyService) - if (!isCloned) { - return ( - - Not cloned - - ) - } - const loadBranches = async () => { - if (branches) return setIsLoading(true) try { const result = await api.call({ @@ -42,188 +213,75 @@ export const BranchSelector = Shade({ }) setBranches({ local: result.result.local, remote: result.result.remote }) } catch { - noty.emit('onNotyAdded', { - title: 'Error', - body: 'Failed to load branches', - type: 'error', - }) + noty.emit('onNotyAdded', { title: 'Error', body: 'Failed to load branches', type: 'error' }) } finally { setIsLoading(false) } } - const handleCheckout = async (branch: string) => { - if (branch === currentBranch) return - setIsCheckingOut(true) - try { - await api.call({ - method: 'POST', - action: '/services/:id/checkout', - url: { id: serviceId }, - body: { branch }, - }) - noty.emit('onNotyAdded', { - title: 'Branch changed', - body: `Switched to ${branch}`, - type: 'success', - }) - setIsOpen(false) - setBranches(null) - } catch (error) { - noty.emit('onNotyAdded', { - title: 'Checkout failed', - body: error instanceof Error ? error.message : 'Failed to checkout branch', - type: 'error', - }) - } finally { - setIsCheckingOut(false) - } - } + useDisposable( + `load-branches-${serviceId}`, + () => { + if (isCloned) void loadBranches() + return { [Symbol.dispose]: () => {} } + }, + [isCloned], + ) - const toggleDropdown = () => { - const willOpen = !isOpen - setIsOpen(willOpen) - if (willOpen) { - void loadBranches() - } + if (!isCloned) { + return ( + + Not cloned + + ) } - const allBranches = branches - ? [ - ...branches.local.map((b) => ({ name: b, type: 'local' as const })), - ...branches.remote.filter((b) => !b.endsWith('/HEAD')).map((b) => ({ name: b, type: 'remote' as const })), - ] - : [] + const handleOpen = () => { + if (!triggerRef.current || !branches) return - const filteredBranches = filter - ? allBranches.filter((b) => b.name.toLowerCase().includes(filter.toLowerCase())) - : allBranches - - const branchLabel = currentBranch ?? 'Select branch...' + showBranchDropdown({ + anchor: triggerRef.current, + branches, + currentBranch, + onSelect: (branch) => { + if (branch === currentBranch) return + setIsCheckingOut(true) + void api + .call({ + method: 'POST', + action: '/services/:id/checkout', + url: { id: serviceId }, + body: { branch }, + }) + .then(() => { + noty.emit('onNotyAdded', { title: 'Branch changed', body: `Switched to ${branch}`, type: 'success' }) + void loadBranches() + }) + .catch((error: unknown) => { + noty.emit('onNotyAdded', { + title: 'Checkout failed', + body: error instanceof Error ? error.message : 'Failed to checkout branch', + type: 'error', + }) + }) + .finally(() => setIsCheckingOut(false)) + }, + }) + } return ( -
- - - {isOpen ? ( -
-
- setFilter((ev.target as HTMLInputElement).value)} - style={{ - width: '100%', - padding: '6px 10px', - border: `1px solid ${cssVariableTheme.divider}`, - borderRadius: cssVariableTheme.shape.borderRadius.sm, - backgroundColor: cssVariableTheme.background.default, - color: cssVariableTheme.text.primary, - fontSize: cssVariableTheme.typography.fontSize.sm, - outline: 'none', - boxSizing: 'border-box', - fontFamily: 'inherit', - }} - /> -
- - {isLoading ? ( -
- Loading... -
- ) : filteredBranches.length === 0 ? ( -
- {filter ? 'No matching branches' : 'No branches found'} -
- ) : ( -
- {filteredBranches.map((branch) => { - const isCurrent = branch.name === currentBranch - return ( - - ) - })} -
- )} -
- ) : null} - - {isOpen ? ( -
setIsOpen(false)} - /> - ) : null} -
+ ) }, }) diff --git a/frontend/src/components/create-monaco-theme.ts b/frontend/src/components/create-monaco-theme.ts new file mode 100644 index 0000000..902647d --- /dev/null +++ b/frontend/src/components/create-monaco-theme.ts @@ -0,0 +1,147 @@ +import type { DeepPartial } from '@furystack/utils' + +import { getRgbFromColorString, getTextColor, type Theme } from '@furystack/shades-common-components' + +type BuiltinTheme = 'vs' | 'vs-dark' | 'hc-black' | 'hc-light' + +/** + * Must stay in sync with `MonacoThemeData` in `monaco-mfe/src/theme.ts`. + * Defined separately because the MFE is loaded at runtime and cannot share + * compile-time types with the host. + */ +export type MonacoThemeData = { + base: BuiltinTheme + inherit: boolean + rules: Array<{ token: string; foreground?: string; background?: string; fontStyle?: string }> + colors: Record +} + +const SHADES_THEME_NAME = 'shades-theme' + +const toHex = (n: number): string => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, '0') + +const rgbToHex = (color: string): string => { + const { r, g, b, a } = getRgbFromColorString(color) + const base = `#${toHex(r)}${toHex(g)}${toHex(b)}` + return a < 1 ? `${base}${toHex(a * 255)}` : base +} + +const withAlpha = (hex: string, alpha: number): string => { + const base = hex.length === 9 ? hex.slice(0, 7) : hex + return `${base}${toHex(alpha * 255)}` +} + +/** + * Creates a Monaco `IStandaloneThemeData` from a FuryStack Shades theme. + * Inherits syntax highlighting from the closest built-in base (`vs` or `vs-dark`) + * and maps Shades design tokens to Monaco editor chrome colors. + */ +export const createMonacoTheme = (theme: DeepPartial): { name: string; data: MonacoThemeData } => { + const bg = theme.background?.default + let base: BuiltinTheme = 'vs-dark' + let detected = false + + if (bg) { + try { + base = getTextColor(bg, 'vs', 'vs-dark') as BuiltinTheme + detected = true + } catch { + // Background color detection failed, will try text color + } + } + + if (!detected && theme.text?.primary) { + try { + base = getTextColor(theme.text.primary, 'vs-dark', 'vs') as BuiltinTheme + } catch (e) { + console.warn('Failed to determine Monaco base theme, falling back to vs-dark', e) + } + } + + const colors: Record = {} + + const map = (monacoKey: string, color: string | undefined) => { + if (!color) return + try { + colors[monacoKey] = rgbToHex(color) + } catch { + // skip unresolvable colors + } + } + + const mapWithAlpha = (monacoKey: string, color: string | undefined, alpha: number) => { + if (!color) return + try { + colors[monacoKey] = withAlpha(rgbToHex(color), alpha) + } catch { + // skip unresolvable colors + } + } + + map('editor.background', theme.background?.default) + map('editor.foreground', theme.text?.primary) + + map('editorLineNumber.foreground', theme.text?.secondary) + map('editorLineNumber.activeForeground', theme.text?.primary) + map('editorLineNumber.dimmedForeground', theme.text?.disabled) + + map('editorCursor.foreground', theme.palette?.primary?.main) + + mapWithAlpha('editor.selectionBackground', theme.palette?.primary?.main, 0.35) + mapWithAlpha('editor.selectionHighlightBackground', theme.palette?.primary?.main, 0.15) + mapWithAlpha('editor.inactiveSelectionBackground', theme.palette?.primary?.main, 0.2) + + map('editor.lineHighlightBackground', theme.action?.selectedBackground) + map('editor.hoverHighlightBackground', theme.action?.hoverBackground) + + mapWithAlpha('editor.findMatchBackground', theme.palette?.warning?.main, 0.4) + mapWithAlpha('editor.findMatchHighlightBackground', theme.palette?.warning?.main, 0.2) + + map('editorGutter.background', theme.background?.default) + + map('editorWhitespace.foreground', theme.text?.disabled) + + map('editorWidget.background', theme.background?.paper) + map('editorWidget.foreground', theme.text?.primary) + map('editorWidget.border', theme.divider) + map('editorHoverWidget.background', theme.background?.paper) + map('editorHoverWidget.foreground', theme.text?.primary) + map('editorHoverWidget.border', theme.divider) + map('editorSuggestWidget.background', theme.background?.paper) + map('editorSuggestWidget.foreground', theme.text?.primary) + map('editorSuggestWidget.border', theme.divider) + mapWithAlpha('editorSuggestWidget.selectedBackground', theme.palette?.primary?.main, 0.2) + + map('editorError.foreground', theme.palette?.error?.main) + map('editorWarning.foreground', theme.palette?.warning?.main) + map('editorInfo.foreground', theme.palette?.info?.main) + map('editorHint.foreground', theme.palette?.success?.main) + + map('editorOverviewRuler.errorForeground', theme.palette?.error?.main) + map('editorOverviewRuler.warningForeground', theme.palette?.warning?.main) + map('editorOverviewRuler.infoForeground', theme.palette?.info?.main) + + map('editorLink.activeForeground', theme.palette?.primary?.main) + + mapWithAlpha('editorBracketMatch.background', theme.palette?.primary?.main, 0.2) + map('editorBracketMatch.border', theme.palette?.primary?.main) + + map('editorCodeLens.foreground', theme.text?.secondary) + + mapWithAlpha('scrollbarSlider.background', theme.text?.secondary, 0.2) + mapWithAlpha('scrollbarSlider.hoverBackground', theme.text?.secondary, 0.35) + mapWithAlpha('scrollbarSlider.activeBackground', theme.text?.secondary, 0.5) + + return { + name: theme.name || SHADES_THEME_NAME, + data: { + base, + inherit: true, + rules: [], + colors, + }, + } +} diff --git a/frontend/src/components/entity-forms/github-repo-form.spec.ts b/frontend/src/components/entity-forms/github-repo-form.spec.ts new file mode 100644 index 0000000..910c78d --- /dev/null +++ b/frontend/src/components/entity-forms/github-repo-form.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../app-routes.js', () => ({})) + +import { isGitHubRepoFormPayload } from './github-repo-form.js' + +describe('isGitHubRepoFormPayload', () => { + const validPayload = { + url: 'https://github.com/org/repo', + displayName: 'My Repo', + description: 'A GitHub repository', + } + + it('should accept a valid payload', () => { + expect(isGitHubRepoFormPayload(validPayload)).toBe(true) + }) + + it('should accept payload without description (not validated)', () => { + expect(isGitHubRepoFormPayload({ url: 'https://github.com/a/b', displayName: 'R' })).toBe(true) + }) + + it('should throw on null (no null guard)', () => { + expect(() => isGitHubRepoFormPayload(null)).toThrow() + }) + + it('should throw on undefined (no null guard)', () => { + expect(() => isGitHubRepoFormPayload(undefined)).toThrow() + }) + + it('should reject when url is missing', () => { + expect(isGitHubRepoFormPayload({ displayName: 'R' })).toBe(false) + }) + + it('should reject when url is empty', () => { + expect(isGitHubRepoFormPayload({ ...validPayload, url: '' })).toBe(false) + }) + + it('should reject when displayName is missing', () => { + expect(isGitHubRepoFormPayload({ url: 'https://github.com/a/b' })).toBe(false) + }) + + it('should reject when displayName is empty', () => { + expect(isGitHubRepoFormPayload({ ...validPayload, displayName: '' })).toBe(false) + }) +}) diff --git a/frontend/src/components/entity-forms/github-repo-form.tsx b/frontend/src/components/entity-forms/github-repo-form.tsx index d382dd1..f81f5e2 100644 --- a/frontend/src/components/entity-forms/github-repo-form.tsx +++ b/frontend/src/components/entity-forms/github-repo-form.tsx @@ -10,7 +10,7 @@ type GitHubRepoFormPayload = { description: string } -const isGitHubRepoFormPayload = (data: unknown): data is GitHubRepoFormPayload => { +export const isGitHubRepoFormPayload = (data: unknown): data is GitHubRepoFormPayload => { const d = data as GitHubRepoFormPayload return d.url?.length > 0 && d.displayName?.length > 0 } diff --git a/frontend/src/components/entity-forms/prerequisite-form.spec.ts b/frontend/src/components/entity-forms/prerequisite-form.spec.ts new file mode 100644 index 0000000..bce06a4 --- /dev/null +++ b/frontend/src/components/entity-forms/prerequisite-form.spec.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' + +import type { PrerequisiteFormPayload } from './prerequisite-form.js' +import { buildConfig, isPrerequisiteFormPayload } from './prerequisite-form.js' + +describe('isPrerequisiteFormPayload', () => { + describe('common field validation', () => { + it('should throw on null (no null guard)', () => { + expect(() => isPrerequisiteFormPayload(null)).toThrow() + }) + + it('should throw on undefined (no null guard)', () => { + expect(() => isPrerequisiteFormPayload(undefined)).toThrow() + }) + + it('should reject a non-object', () => { + expect(isPrerequisiteFormPayload('string')).toBe(false) + }) + + it('should reject when name is missing', () => { + expect(isPrerequisiteFormPayload({ type: 'git' })).toBe(false) + }) + + it('should reject when name is empty', () => { + expect(isPrerequisiteFormPayload({ name: '', type: 'git' })).toBe(false) + }) + + it('should reject when type is missing', () => { + expect(isPrerequisiteFormPayload({ name: 'Git' })).toBe(false) + }) + + it('should reject when type is empty', () => { + expect(isPrerequisiteFormPayload({ name: 'Git', type: '' })).toBe(false) + }) + }) + + describe('node type', () => { + it('should accept valid node payload', () => { + expect(isPrerequisiteFormPayload({ name: 'Node', type: 'node', minimumVersion: '18.0.0' })).toBe(true) + }) + + it('should reject node without minimumVersion', () => { + expect(isPrerequisiteFormPayload({ name: 'Node', type: 'node' })).toBe(false) + }) + + it('should reject node with empty minimumVersion', () => { + expect(isPrerequisiteFormPayload({ name: 'Node', type: 'node', minimumVersion: '' })).toBe(false) + }) + }) + + describe('yarn type', () => { + it('should accept valid yarn payload', () => { + expect(isPrerequisiteFormPayload({ name: 'Yarn', type: 'yarn', minimumVersion: '4.0.0' })).toBe(true) + }) + + it('should reject yarn without minimumVersion', () => { + expect(isPrerequisiteFormPayload({ name: 'Yarn', type: 'yarn' })).toBe(false) + }) + + it('should reject yarn with empty minimumVersion', () => { + expect(isPrerequisiteFormPayload({ name: 'Yarn', type: 'yarn', minimumVersion: '' })).toBe(false) + }) + }) + + describe('dotnet-sdk type', () => { + it('should accept valid dotnet-sdk payload', () => { + expect(isPrerequisiteFormPayload({ name: '.NET SDK', type: 'dotnet-sdk', version: '8.0' })).toBe(true) + }) + + it('should reject dotnet-sdk without version', () => { + expect(isPrerequisiteFormPayload({ name: '.NET SDK', type: 'dotnet-sdk' })).toBe(false) + }) + + it('should reject dotnet-sdk with empty version', () => { + expect(isPrerequisiteFormPayload({ name: '.NET SDK', type: 'dotnet-sdk', version: '' })).toBe(false) + }) + }) + + describe('dotnet-runtime type', () => { + it('should accept valid dotnet-runtime payload', () => { + expect(isPrerequisiteFormPayload({ name: '.NET Runtime', type: 'dotnet-runtime', version: '8.0' })).toBe(true) + }) + + it('should reject dotnet-runtime without version', () => { + expect(isPrerequisiteFormPayload({ name: '.NET Runtime', type: 'dotnet-runtime' })).toBe(false) + }) + }) + + describe('nuget-feed type', () => { + it('should accept valid nuget-feed payload', () => { + expect( + isPrerequisiteFormPayload({ + name: 'My Feed', + type: 'nuget-feed', + feedUrl: 'https://nuget.example.com/v3/index.json', + }), + ).toBe(true) + }) + + it('should reject nuget-feed without feedUrl', () => { + expect(isPrerequisiteFormPayload({ name: 'My Feed', type: 'nuget-feed' })).toBe(false) + }) + + it('should reject nuget-feed with empty feedUrl', () => { + expect(isPrerequisiteFormPayload({ name: 'My Feed', type: 'nuget-feed', feedUrl: '' })).toBe(false) + }) + }) + + describe('git type', () => { + it('should accept valid git payload', () => { + expect(isPrerequisiteFormPayload({ name: 'Git', type: 'git' })).toBe(true) + }) + }) + + describe('github-cli type', () => { + it('should accept valid github-cli payload', () => { + expect(isPrerequisiteFormPayload({ name: 'GitHub CLI', type: 'github-cli' })).toBe(true) + }) + }) + + describe('env-variable type', () => { + it('should accept valid env-variable payload', () => { + expect(isPrerequisiteFormPayload({ name: 'GH Token', type: 'env-variable', variableName: 'GITHUB_TOKEN' })).toBe( + true, + ) + }) + + it('should reject env-variable without variableName', () => { + expect(isPrerequisiteFormPayload({ name: 'GH Token', type: 'env-variable' })).toBe(false) + }) + + it('should reject env-variable with empty variableName', () => { + expect(isPrerequisiteFormPayload({ name: 'GH Token', type: 'env-variable', variableName: '' })).toBe(false) + }) + }) + + describe('custom-script type', () => { + it('should accept valid custom-script payload', () => { + expect( + isPrerequisiteFormPayload({ name: 'Docker check', type: 'custom-script', script: 'docker --version' }), + ).toBe(true) + }) + + it('should reject custom-script without script', () => { + expect(isPrerequisiteFormPayload({ name: 'Docker check', type: 'custom-script' })).toBe(false) + }) + + it('should reject custom-script with empty script', () => { + expect(isPrerequisiteFormPayload({ name: 'Docker check', type: 'custom-script', script: '' })).toBe(false) + }) + }) +}) + +describe('buildConfig', () => { + it('should return { minimumVersion } for node', () => { + const payload: PrerequisiteFormPayload = { name: 'Node', type: 'node', minimumVersion: '18.0.0' } + expect(buildConfig(payload)).toEqual({ minimumVersion: '18.0.0' }) + }) + + it('should return { minimumVersion } for yarn', () => { + const payload: PrerequisiteFormPayload = { name: 'Yarn', type: 'yarn', minimumVersion: '4.0.0' } + expect(buildConfig(payload)).toEqual({ minimumVersion: '4.0.0' }) + }) + + it('should return { version } for dotnet-sdk', () => { + const payload: PrerequisiteFormPayload = { name: '.NET SDK', type: 'dotnet-sdk', version: '8.0' } + expect(buildConfig(payload)).toEqual({ version: '8.0' }) + }) + + it('should return { version } for dotnet-runtime', () => { + const payload: PrerequisiteFormPayload = { name: '.NET Runtime', type: 'dotnet-runtime', version: '8.0' } + expect(buildConfig(payload)).toEqual({ version: '8.0' }) + }) + + it('should return { feedUrl } for nuget-feed without feedName', () => { + const payload: PrerequisiteFormPayload = { + name: 'Feed', + type: 'nuget-feed', + feedUrl: 'https://nuget.example.com', + } + expect(buildConfig(payload)).toEqual({ feedUrl: 'https://nuget.example.com' }) + }) + + it('should return { feedUrl, feedName } for nuget-feed with feedName', () => { + const payload: PrerequisiteFormPayload = { + name: 'Feed', + type: 'nuget-feed', + feedUrl: 'https://nuget.example.com', + feedName: 'My Feed', + } + expect(buildConfig(payload)).toEqual({ feedUrl: 'https://nuget.example.com', feedName: 'My Feed' }) + }) + + it('should return {} for git', () => { + const payload: PrerequisiteFormPayload = { name: 'Git', type: 'git' } + expect(buildConfig(payload)).toEqual({}) + }) + + it('should return {} for github-cli', () => { + const payload: PrerequisiteFormPayload = { name: 'GH CLI', type: 'github-cli' } + expect(buildConfig(payload)).toEqual({}) + }) + + it('should return { variableName } for env-variable without isSensitive', () => { + const payload: PrerequisiteFormPayload = { + name: 'Token', + type: 'env-variable', + variableName: 'GITHUB_TOKEN', + } + expect(buildConfig(payload)).toEqual({ variableName: 'GITHUB_TOKEN' }) + }) + + it('should return { variableName, isSensitive } when isSensitive is "on"', () => { + const payload: PrerequisiteFormPayload = { + name: 'Token', + type: 'env-variable', + variableName: 'GITHUB_TOKEN', + isSensitive: 'on', + } + expect(buildConfig(payload)).toEqual({ variableName: 'GITHUB_TOKEN', isSensitive: true }) + }) + + it('should not include isSensitive when it is not "on"', () => { + const payload: PrerequisiteFormPayload = { + name: 'Token', + type: 'env-variable', + variableName: 'GITHUB_TOKEN', + isSensitive: 'off', + } + expect(buildConfig(payload)).toEqual({ variableName: 'GITHUB_TOKEN' }) + }) + + it('should return { script } for custom-script', () => { + const payload: PrerequisiteFormPayload = { + name: 'Docker', + type: 'custom-script', + script: 'docker --version', + } + expect(buildConfig(payload)).toEqual({ script: 'docker --version' }) + }) + + it('should return {} for an unknown type', () => { + const payload = { name: 'Unknown', type: 'unknown-type' } as unknown as PrerequisiteFormPayload + expect(buildConfig(payload)).toEqual({}) + }) +}) diff --git a/frontend/src/components/entity-forms/prerequisite-form.tsx b/frontend/src/components/entity-forms/prerequisite-form.tsx index 07d038b..165bbc3 100644 --- a/frontend/src/components/entity-forms/prerequisite-form.tsx +++ b/frontend/src/components/entity-forms/prerequisite-form.tsx @@ -2,7 +2,7 @@ import { createComponent, Shade } from '@furystack/shades' import { Button, Checkbox, Form, Icon, icons, Input, MarkdownEditor, Select } from '@furystack/shades-common-components' import type { Prerequisite, PrerequisiteConfig, PrerequisiteType } from 'common' -type PrerequisiteFormPayload = { +export type PrerequisiteFormPayload = { name: string type: PrerequisiteType minimumVersion?: string @@ -18,7 +18,7 @@ type PrerequisiteFormPayload = { const TYPES_REQUIRING_MINIMUM_VERSION: PrerequisiteType[] = ['node', 'yarn'] const TYPES_REQUIRING_VERSION: PrerequisiteType[] = ['dotnet-sdk', 'dotnet-runtime'] -const isPrerequisiteFormPayload = (data: unknown): data is PrerequisiteFormPayload => { +export const isPrerequisiteFormPayload = (data: unknown): data is PrerequisiteFormPayload => { const d = data as PrerequisiteFormPayload if (!d.name || d.name.length === 0) return false if (!d.type || d.type.length === 0) return false @@ -36,7 +36,7 @@ const isPrerequisiteFormPayload = (data: unknown): data is PrerequisiteFormPaylo return true } -const buildConfig = (data: PrerequisiteFormPayload): PrerequisiteConfig => { +export const buildConfig = (data: PrerequisiteFormPayload): PrerequisiteConfig => { switch (data.type) { case 'node': case 'yarn': diff --git a/frontend/src/components/entity-forms/service-form.tsx b/frontend/src/components/entity-forms/service-form.tsx deleted file mode 100644 index bed38d6..0000000 --- a/frontend/src/components/entity-forms/service-form.tsx +++ /dev/null @@ -1,599 +0,0 @@ -import { createComponent, Shade } from '@furystack/shades' -import { - Button, - Checkbox, - cssVariableTheme, - Form, - Icon, - icons, - Input, - MarkdownEditor, - Paper, - Select, -} from '@furystack/shades-common-components' -import type { GitHubRepository, Prerequisite, ServiceFile, ServiceView } from 'common' - -import { StackCraftNestedRouteLink } from '../app-routes.js' -import { prerequisiteTypeLabels } from '../status-chips.js' -import { GitHubRepoForm } from './github-repo-form.js' -import { PrerequisiteForm } from './prerequisite-form.js' - -type ServiceFormPayload = { - displayName: string - description: string - workingDirectory: string - repositoryId: string - runCommand: string - installCommand: string - buildCommand: string - autoFetchEnabled: string - autoFetchIntervalMinutes: string - autoRestartOnFetch: string -} - -const isServiceFormPayload = (data: unknown): data is ServiceFormPayload => { - const d = data as ServiceFormPayload - return d.displayName?.length > 0 && d.runCommand?.length > 0 -} - -type ServiceFormProps = { - initial?: Partial - stackName: string - repositories?: GitHubRepository[] - prerequisites?: Prerequisite[] - otherServices?: Array<{ id: string; displayName: string }> - onSubmit: (data: Partial) => void | Promise - onCreatePrerequisite?: (data: Partial) => Promise - onCreateRepository?: (data: Partial) => Promise - onCancel?: () => void - cancelHref?: string - mode: 'create' | 'edit' -} - -export const ServiceForm = Shade({ - customElementName: 'shade-service-form', - render: ({ props, useState }) => { - const [selectedPrereqIds, setSelectedPrereqIds] = useState( - 'selectedPrereqIds', - props.initial?.prerequisiteIds ?? [], - ) - const [selectedPrereqServiceIds, setSelectedPrereqServiceIds] = useState( - 'selectedPrereqServiceIds', - props.initial?.prerequisiteServiceIds ?? [], - ) - const [isCreatingPrereq, setIsCreatingPrereq] = useState('isCreatingPrereq', false) - const [isCreatingRepo, setIsCreatingRepo] = useState('isCreatingRepo', false) - const [sharedFiles, setSharedFiles] = useState('sharedFiles', props.initial?.files ?? []) - const [localFiles, setLocalFiles] = useState('localFiles', props.initial?.localFiles ?? []) - - const togglePrereqId = (id: string) => { - const updated = selectedPrereqIds.includes(id) - ? selectedPrereqIds.filter((pid) => pid !== id) - : [...selectedPrereqIds, id] - setSelectedPrereqIds(updated) - } - - const togglePrereqServiceId = (id: string) => { - const updated = selectedPrereqServiceIds.includes(id) - ? selectedPrereqServiceIds.filter((sid) => sid !== id) - : [...selectedPrereqServiceIds, id] - setSelectedPrereqServiceIds(updated) - } - - const repoOptions = [ - { value: '', label: '(None)' }, - ...(props.repositories ?? []).map((r) => ({ value: r.id, label: r.displayName })), - ] - - return ( -
- - validate={isServiceFormPayload} - onSubmit={(data) => - props.onSubmit({ - stackName: props.stackName, - displayName: data.displayName, - description: data.description, - workingDirectory: data.workingDirectory || undefined, - repositoryId: data.repositoryId || undefined, - runCommand: data.runCommand, - installCommand: data.installCommand || undefined, - buildCommand: data.buildCommand || undefined, - autoFetchEnabled: data.autoFetchEnabled === 'on', - autoFetchIntervalMinutes: parseInt(data.autoFetchIntervalMinutes, 10) || 60, - autoRestartOnFetch: data.autoRestartOnFetch === 'on', - prerequisiteIds: selectedPrereqIds, - prerequisiteServiceIds: selectedPrereqServiceIds, - files: sharedFiles.filter((f) => f.relativePath.trim().length > 0), - localFiles: localFiles.filter((f) => f.relativePath.trim().length > 0), - }) - } - disableOnSubmit - style={{ display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '600px' }} - > -

{props.mode === 'create' ? 'Create Service' : 'Edit Service'}

- -

Definition

- - - -
-
- - 'Optional. Relative path within stack for grouping, e.g. frontends/public or services/gateways' - } - /> - 'e.g., npm start, yarn dev, dotnet run'} - /> - 'e.g., npm install, yarn, dotnet restore'} - /> - 'e.g., npm run build, yarn build, dotnet build'} - /> - -
-

Shared Files

-

- Files placed relative to the service root (e.g. .env, appConfig.local.json). -

- {sharedFiles.length > 0 ? ( -
- {sharedFiles.map((file, index) => ( -
-
- { - const updated = [...sharedFiles] - updated[index] = { ...updated[index], relativePath: (ev.target as HTMLInputElement).value } - setSharedFiles(updated) - }} - style={{ - flex: '1', - padding: '6px 10px', - borderRadius: '4px', - border: `1px solid ${cssVariableTheme.divider}`, - background: 'transparent', - color: 'inherit', - fontFamily: 'monospace', - fontSize: cssVariableTheme.typography.fontSize.sm, - }} - /> - -
-