diff --git a/.cursor/rules/CODE_STYLE.mdc b/.cursor/rules/CODE_STYLE.mdc index 9554584..7602f70 100644 --- a/.cursor/rules/CODE_STYLE.mdc +++ b/.cursor/rules/CODE_STYLE.mdc @@ -38,13 +38,13 @@ yarn lint ### Automated Formatting -Code is automatically formatted on commit via Husky: +Code is automatically formatted on commit via Husky and lint-staged: ```json { - "lint-staged": { - "*.{ts,tsx}": ["eslint --fix", "prettier --write", "git add"] - } + "lint-staged": { + "*.{ts,tsx}": ["eslint --cache --fix", "prettier --write"] + } } ``` diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d63ae07 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL=postgres://stackcraft:stackcraft@localhost:5433/stackcraft diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 1dcb862..75dbe8b 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -10,6 +10,24 @@ jobs: matrix: node-version: [22.x, 24.x] + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: stackcraft + POSTGRES_PASSWORD: stackcraft + POSTGRES_DB: stackcraft + ports: + - 5433:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgres://stackcraft:stackcraft@localhost:5433/stackcraft + steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} diff --git a/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec..0000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.yarn/changelogs/common.6a9239ac.md b/.yarn/changelogs/common.6a9239ac.md new file mode 100644 index 0000000..9f295b7 --- /dev/null +++ b/.yarn/changelogs/common.6a9239ac.md @@ -0,0 +1,52 @@ + +# common + +## ✨ Features + +### Domain Model Layer + +Added a full domain model layer for stack and service management, replacing the previous minimal `User`-only model surface: + +- `StackDefinition` / `StackConfig` — stack metadata, main directory, and environment variable configuration +- `ServiceDefinition` / `ServiceConfig` — service commands, working directory, file definitions, and prerequisite associations +- `ServiceStatus` — typed status per pipeline stage (`CloneStatus`, `InstallStatus`, `BuildStatus`, `RunStatus`) +- `ServiceGitStatus` — branch name, commits behind remote, and auto-fetch flag +- `ServiceStateHistory` / `ServiceStateEvent` — audit trail of state transitions with trigger source tracking +- `ServiceLogEntry` — structured log entries per service process +- `GitHubRepository` — linked Git repository with URL and branch configuration +- `Prerequisite` / `PrerequisiteCheckResult` — typed prerequisites (Node.js, Yarn, .NET, NuGet, env var, custom script) with check status +- `ApiToken` / `PublicApiToken` — bearer token management for REST and MCP authentication +- `EnvironmentVariableValue` — environment variables with `isSecret` flag for encryption support +- `StackView` / `ServiceView` — entity views for filtered data projection + +### Typed REST API Definitions + +Replaced the single monolithic `StackCraftApi` with individual typed API interfaces, each with its own generated JSON schema: + +- `IdentityApi` — authentication, current user, login/logout, password reset +- `InstallApi` — first-run service status and installation trigger +- `StacksApi` — stack CRUD, import/export, and setup orchestration +- `ServicesApi` — service CRUD, lifecycle actions (start/stop/restart/install/build/pull/setup/update), logs, history, branches, checkout, and file application +- `GitHubRepositoriesApi` — repository CRUD with URL validation +- `PrerequisitesApi` — prerequisite CRUD with on-demand check execution +- `TokensApi` — API token creation, listing, and deletion +- `SystemApi` — environment variable availability checks + +### Service Path Utilities + +- Added `getRepoNameFromUrl()` to extract repository names from Git URLs +- Added `getServiceCwd()` to resolve a service's working directory from stack config, service definition, and repository URL + +## ♻️ Refactoring + +- Removed `StackCraftApi` and `BoilerplateApi` single-API types in favor of per-domain API modules +- Removed test-only endpoints (`TestQueryEndpoint`, `TestUrlParamsEndpoint`, `TestPostBodyEndpoint`) +- Schema generation now emits one JSON file per API module instead of a single `stack-craft-api.json` + +## 🧪 Tests + +- Added unit tests for `getRepoNameFromUrl()` and `getServiceCwd()` path utilities + +## ⬆️ Dependencies + +- Added `@furystack/core` for shared entity types (`WithOptionalId`) diff --git a/.yarn/changelogs/frontend.6a9239ac.md b/.yarn/changelogs/frontend.6a9239ac.md new file mode 100644 index 0000000..c69dc93 --- /dev/null +++ b/.yarn/changelogs/frontend.6a9239ac.md @@ -0,0 +1,90 @@ + +# frontend + +## ✨ Features + +### Nested Routing System + +Implemented a full nested routing system with typed navigation helpers (`StackCraftNestedRouteLink`, `stackCraftNavigate`) and URL-driven breadcrumb navigation. All routes are scoped under stacks for contextual navigation. + +### Dashboard + +Stack overview page displaying all stacks with entity-sync–powered live updates. Selecting a stack shows its services, status, and quick actions. + +### Service Management + +- **Services list** with pipeline status indicators (clone → install → build → run) and bulk actions +- **Service detail page** with pipeline stepper, log viewer, branch selector, environment overrides, prerequisite status, and lifecycle controls (start/stop/restart/install/build/pull/setup/update) +- **Service logs** with ANSI color rendering, per-process filtering, and log clearing +- **Create service wizard** with two-step flow: define service then optional automated setup + +### Stack Management + +- **Create/edit stack** with name, display name, main directory, and markdown description +- **Stack import/export** for portability between environments with environment variable remapping +- **Stack setup** progress page showing clone/install/build readiness per service + +### Repository Management + +- **Repository list/create/edit** pages for managing linked GitHub repositories with URL validation + +### Prerequisites Management + +- **Prerequisites list** with check status display and typed creation form supporting Node.js, Yarn, .NET SDK, NuGet feeds, environment variables, and custom scripts + +### User Settings + +- Theme selection with persistent storage +- Password change form +- API token management (create, list, delete) + +### Installer Wizard + +Full-screen first-run wizard with prerequisite checks, admin user creation, and success confirmation — shown automatically when the backend reports an uninstalled state. + +### Layout and Navigation + +- **Sidebar** with collapsible per-stack navigation sections (services, repositories, prerequisites, import/export) +- **Header** with breadcrumbs, theme toggle, and logout +- **Entity sync** via WebSocket (`/api/ws`) for real-time collection updates across all views + +### UI Components + +- `ServicePipelineStepper` / `MiniPipelineDots` — visualize clone/install/build/run stages with color-coded status +- `BranchSelector` — Git branch switching with remote branch listing +- `EnvironmentVariablesManager` — stack-level env var editing with secret masking and availability checks +- `ServiceEnvOverrides` — per-service environment variable override editing +- `LogViewer` / `LogLine` — streaming log display with ANSI escape sequence rendering +- `PrerequisiteTable` / `PrerequisiteList` — prerequisite display with check status chips +- `ServiceTable` / `RepositoryTable` — data tables with status indicators and actions +- `StatusChips` — colored status badges for pipeline stages and prerequisite types + +### Utilities + +- `parseAnsi()` — parses ANSI escape sequences into styled segments for terminal log rendering +- `getServicePipelineStages()` / `getServicePrimaryAction()` — derive pipeline visualization data and contextual actions from service state +- `applyClientFindOptions()` — client-side `FindOptions` filtering, ordering, and pagination + +### Per-API REST Clients + +Replaced monolithic `StackCraftApiClient` with individual typed clients: `IdentityApiClient`, `StacksApiClient`, `ServicesApiClient`, `GitHubReposApiClient`, `PrerequisitesApiClient`, `TokensApiClient`, `SystemApiClient`, `InstallApiClient`. + +## ♻️ Refactoring + +- Restructured app shell into `Header` + `Sidebar` + `Body` layout with auth/install gating +- Replaced `StackCraftApiClient` and `BoilerplateApiClient` with per-domain API clients +- Session service now uses `IdentityApiClient` with full `User` type and `[Symbol.dispose]` cleanup + +## 🧪 Tests + +- Added unit tests for ANSI escape sequence parser (`parse-ansi.spec.ts`) +- Added unit tests for service pipeline stage derivation and action mapping (`service-pipeline.spec.ts`) + +## 📦 Build + +- Migrated to Vite 8 with synchronous `defineConfig` + +## ⬆️ Dependencies + +- Added `@furystack/cache`, `@furystack/entity-sync`, `@furystack/entity-sync-client` for live data sync +- Bumped Vite to v8, Vitest to v4 diff --git a/.yarn/changelogs/service.6a9239ac.md b/.yarn/changelogs/service.6a9239ac.md new file mode 100644 index 0000000..71afef1 --- /dev/null +++ b/.yarn/changelogs/service.6a9239ac.md @@ -0,0 +1,91 @@ + +# service + +## ✨ Features + +### Process Manager + +Added `ProcessManager` to orchestrate service lifecycle (clone, install, build, run, pull, setup, update) with child process management, `ServiceStateHistory` audit trail, and batched log forwarding to `LogStorageService`. Automatically reconciles stale states on startup. + +### Git Integration + +- `GitService` — wrapper for git operations: clone, fetch, pull, list branches, current branch, commits behind remote, and checkout +- `GitWatcher` — periodic remote fetch (default 5 min) per service with auto-fetch toggle; updates `ServiceGitStatus` and service status when remotes change +- `GitHeadWatcher` — monitors `.git/HEAD` for branch switches and updates branch/behind counts in `ServiceGitStatus` + +### MCP Server + +Exposed a Model Context Protocol server (Streamable HTTP on port 9091) with bearer token authentication and tools for: + +- `stack-tools` — list, get, create, update, delete, export, import, and setup stacks +- `service-tools` — list, create, edit, delete services; start/stop/restart/install/build/pull/setup/update lifecycle; get logs and state history +- `prerequisite-tools` — list, check, create, update, and delete prerequisites +- `repository-tools` — list, get, create, update, delete, and validate repositories +- `env-variable-tools` — set/remove stack env variables and service env overrides +- `service-file-tools` — manage shared and local encrypted service files (list/read/add/update/remove/apply) +- `system-tools` — check environment variable availability + +### Security + +- `CryptoService` with AES-256-GCM encryption for sensitive environment variables and service files, keyed from `STACK_CRAFT_ENCRYPTION_KEY` or auto-generated `~/.stack-craft/encryption.key` +- `SecretDetector` with heuristic scanning for secrets in commands and file content +- Bearer API token authentication (`BearerTokenAuth`) with SHA-256 hashed storage; plaintext returned only at creation time +- Sensitive data masking on REST responses for environment variables and service files +- Startup migration (`encryptExistingSecrets`) to encrypt pre-existing plaintext secrets in the database + +### Prerequisite Evaluation + +Prerequisite check system supporting Node.js version, Yarn version, .NET SDK version, NuGet feed availability, environment variable presence, and custom script execution. Evaluates all prerequisites in the background after startup. + +### Stack Import/Export + +- `exportStackAction` — serializes a stack with its services, repositories, and prerequisites to JSON +- `importStackAction` — imports a stack from JSON with environment variable remapping, deduplication, and conflict resolution + +### WebSocket Entity Sync + +Real-time entity synchronization via WebSocket at `/api/ws` using `SyncSubscribeAction` / `SyncUnsubscribeAction` for live frontend updates. + +### Additional REST Endpoints + +- Password reset flow via `/api/identity/password-reset` +- `LogStorageService` for per-service log persistence, query, and pruning +- Repository URL validation via `git ls-remote` +- Environment variable availability check at `/api/system/check-env-availability` +- Service file application to disk via `/api/services/:id/apply-files` + +## ♻️ Refactoring + +### PostgreSQL Migration + +Migrated from `@furystack/filesystem-store` (file-backed JSON) to PostgreSQL via `@furystack/sequelize-store` with Sequelize models and JSONB columns for flexible fields. In-memory stores retained for transient data (`ServiceGitStatus`, `PrerequisiteCheckResult`, sessions, log entries). + +### REST API Reorganization + +Restructured the REST API into domain-specific modules: identity, install, stacks, services, github-repositories, prerequisites, tokens, and system — each with dedicated setup, actions, and authorization. + +### Seed Removal + +Replaced the `seed.ts` script with an installer flow triggered from the frontend on first run. + +## 🧪 Tests + +- `ProcessManager` — lifecycle orchestration, state transitions, log batching, stale state reconciliation +- `GitService` — clone, fetch, pull, branches, current branch, commits behind, checkout +- `CryptoService` — encrypt/decrypt round-trip, key generation, tamper detection +- `env-encryption-helpers` — encrypt/mask/decrypt for stack and service payloads, `UNCHANGED_SENTINEL` handling +- `secret-detector` — heuristic pattern matching for secrets in commands and file content +- `apply-service-files` — merge and write service file definitions to disk +- `check-prerequisite-action` — all prerequisite types and edge cases +- `service-branches-action` / `service-checkout-action` — branch listing and checkout flows +- `service-lifecycle-action` — lifecycle action dispatching +- `import-export-actions` — stack export/import with remapping and deduplication +- `setup-tokens-rest-api` — token CRUD with hashed storage +- `bearer-token-auth` — token resolution and user loading +- `service-installer` — installation flow +- `config` / `get-cors-options` / `get-port` — configuration resolution + +## ⬆️ Dependencies + +- Added `sequelize`, `pg`, `pg-hstore`, `@furystack/sequelize-store` for PostgreSQL support +- Removed `@furystack/filesystem-store` diff --git a/.yarn/changelogs/stack-craft.6a9239ac.md b/.yarn/changelogs/stack-craft.6a9239ac.md new file mode 100644 index 0000000..7801e8d --- /dev/null +++ b/.yarn/changelogs/stack-craft.6a9239ac.md @@ -0,0 +1,34 @@ + +# stack-craft + +## ✨ Features + +- Added PostgreSQL via `docker-compose.yml` for local development (port 5433, `postgres:17-alpine`) +- Added `.env.example` with `DATABASE_URL` configuration for quick setup +- Added separate E2E test suites: installer flow (`test:e2e:install`), smoke tests (`smoke.spec.ts`), and dogfooding tests (`dogfooding.spec.ts`) covering real repository clone/install/build/run + +## ♻️ Refactoring + +- Renamed `test:unit` script to `test` for simplicity +- Split E2E tests into focused scenarios (installer, smoke, dogfooding) replacing the single `page.spec.ts` + +## 👷 CI + +- Added PostgreSQL service container to the `ui-tests` workflow with health checks +- Separated installer E2E (`test:e2e:install`) from app E2E in the CI pipeline +- Moved heavy Playwright tests from `build-test` to `ui-tests` workflow to keep the default build fast + +## 📚 Documentation + +- Updated README with PostgreSQL setup instructions, Docker run example with `DATABASE_URL`, and revised testing commands + +## 📦 Build + +- Upgraded to Yarn 4.13.0 +- Upgraded ESLint to v10 with `@furystack/eslint-plugin` (replacing `eslint-plugin-playwright`) +- Upgraded Vite to v8, Vitest to v4, `@playwright/test` to v1.58 + +## ⬆️ Dependencies + +- Added `jsdom` for Vitest browser environment +- Bumped `typescript-eslint`, `lint-staged`, `rimraf`, `@types/node` diff --git a/.yarn/versions/6a9239ac.yml b/.yarn/versions/6a9239ac.yml new file mode 100644 index 0000000..482b181 --- /dev/null +++ b/.yarn/versions/6a9239ac.yml @@ -0,0 +1,5 @@ +releases: + common: minor + frontend: minor + service: minor + stack-craft: minor diff --git a/README.md b/README.md index d799fa7..29d57ca 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,19 @@ Example web app with common type API definitions, a FuryStack-based backend serv 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) +## Docker + +When running the Docker image, pass the `DATABASE_URL` environment variable: + +```bash +docker run -e DATABASE_URL=postgres://user:password@host:5433/stackcraft furystack/stack-craft +``` + # Testing - You can execute the example Vitest tests with `yarn test` -- You can execute E2E tests with `yarn test:e2e` +- You can execute E2E tests with `yarn test:e2e` (requires a running PostgreSQL instance and `DATABASE_URL` set) diff --git a/common/schemas/entities.json b/common/schemas/entities.json index 05ade07..b302b64 100644 --- a/common/schemas/entities.json +++ b/common/schemas/entities.json @@ -1,31 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "ApiToken": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "username": { - "type": "string" - }, - "name": { - "type": "string" - }, - "tokenHash": { - "type": "string" - }, - "lastUsedAt": { - "type": "string" - }, - "createdAt": { - "type": "string" - } - }, - "required": ["id", "username", "name", "tokenHash", "createdAt"], - "additionalProperties": false - }, "EnvironmentVariableValue": { "type": "object", "properties": { @@ -37,34 +12,33 @@ "customValue": { "type": "string", "description": "The custom value to use when source is 'custom'" + }, + "isSensitive": { + "type": "boolean", + "description": "Whether this value contains sensitive data (e.g. passwords, tokens). When true, the value is encrypted at rest and masked in API responses. Overrides the prerequisite-level default when set." } }, "required": ["source"], "additionalProperties": false, "description": "Describes how an environment variable's value is resolved. Used in {@link StackConfig } for stack-level defaults and in {@link ServiceConfig } for per-service overrides." }, - "GitHubRepository": { + "StackConfig": { "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": { + "mainDirectory": { "type": "string", - "description": "Human-readable name shown in the UI" + "description": "Absolute path to the root directory for all services in this stack" }, - "description": { - "type": "string", - "description": "Optional description" + "environmentVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/EnvironmentVariableValue" + }, + "description": "Stack-level environment variable values, keyed by variable name" }, "createdAt": { "type": "string" @@ -73,25 +47,9 @@ "type": "string" } }, - "required": ["id", "stackName", "url", "displayName", "description", "createdAt", "updatedAt"], + "required": ["stackName", "mainDirectory", "environmentVariables", "createdAt", "updatedAt"], "additionalProperties": false, - "description": "Shareable GitHub repository definition. Links a git repository to a stack for cloning and pulling. Included in stack exports and shared between installations." - }, - "User": { - "type": "object", - "properties": { - "username": { - "type": "string" - }, - "roles": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["username", "roles"], - "additionalProperties": false + "description": "User-specific stack configuration. Contains settings unique to this installation/machine. Not included in exports - set by the user during import/installation." }, "StackDefinition": { "type": "object", @@ -119,35 +77,6 @@ "additionalProperties": false, "description": "Shareable stack definition. Contains the immutable identity and description of a stack. Included in stack exports and shared between installations." }, - "StackConfig": { - "type": "object", - "properties": { - "stackName": { - "type": "string", - "description": "FK to {@link StackDefinition.name }" - }, - "mainDirectory": { - "type": "string", - "description": "Absolute path to the root directory for all services in this stack" - }, - "environmentVariables": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/EnvironmentVariableValue" - }, - "description": "Stack-level environment variable values, keyed by variable name" - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": ["stackName", "mainDirectory", "environmentVariables", "createdAt", "updatedAt"], - "additionalProperties": false, - "description": "User-specific stack configuration. Contains settings unique to this installation/machine. Not included in exports - set by the user during import/installation." - }, "ServiceFile": { "type": "object", "properties": { @@ -272,6 +201,13 @@ }, "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." }, + "localFiles": { + "type": "array", + "items": { + "$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." + }, "createdAt": { "type": "string" }, @@ -285,12 +221,32 @@ "autoFetchIntervalMinutes", "autoRestartOnFetch", "environmentVariableOverrides", + "localFiles", "createdAt", "updatedAt" ], "additionalProperties": false, "description": "User-specific service configuration. Contains settings that each installation can customize independently. Not included in exports - set by the user during import/installation." }, + "ServiceGitStatus": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "description": "FK to {@link import ('./service-definition.js').ServiceDefinition.id }" + }, + "currentBranch": { + "type": "string" + }, + "commitsBehind": { + "type": "number", + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + } + }, + "required": ["serviceId"], + "additionalProperties": false, + "description": "In-memory git state for a service. Derived from the filesystem (`.git/HEAD`) and periodic remote checks, never persisted to DB." + }, "CloneStatus": { "type": "string", "enum": ["not-cloned", "cloning", "cloned", "failed"] @@ -341,13 +297,235 @@ "lastFetchedAt": { "type": "string" }, - "updatedAt": { - "type": "string" + "updatedAt": { + "type": "string" + } + }, + "required": ["serviceId", "cloneStatus", "installStatus", "buildStatus", "runStatus", "updatedAt"], + "additionalProperties": false, + "description": "Runtime status of a service. Managed by the system (ProcessManager, GitWatcher). Never exported. Reset to defaults on import." + }, + "StackView": { + "type": "object", + "additionalProperties": false, + "properties": { + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "mainDirectory": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + }, + "environmentVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/EnvironmentVariableValue" + }, + "description": "Stack-level environment variable values, keyed by variable name" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "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": [ + "createdAt", + "description", + "displayName", + "environmentVariables", + "mainDirectory", + "name", + "stackName", + "updatedAt" + ], + "description": "Full stack view combining definition and config for API responses" + }, + "ServiceView": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "currentBranch": { + "type": "string" + }, + "commitsBehind": { + "type": "number", + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + }, + "cloneStatus": { + "$ref": "#/definitions/CloneStatus" + }, + "installStatus": { + "$ref": "#/definitions/InstallStatus" + }, + "buildStatus": { + "$ref": "#/definitions/BuildStatus" + }, + "runStatus": { + "$ref": "#/definitions/RunStatus" + }, + "lastClonedAt": { + "type": "string" + }, + "lastInstalledAt": { + "type": "string" + }, + "lastBuiltAt": { + "type": "string" + }, + "lastStartedAt": { + "type": "string" + }, + "lastFetchedAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "autoFetchEnabled": { + "type": "boolean", + "description": "Whether automatic git fetch is enabled" + }, + "autoFetchIntervalMinutes": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + }, + "autoRestartOnFetch": { + "type": "boolean", + "description": "Whether to automatically restart the service when new commits are fetched" + }, + "environmentVariableOverrides": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/EnvironmentVariableValue" + }, + "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." + }, + "localFiles": { + "type": "array", + "items": { + "$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." + }, + "createdAt": { + "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 }" + }, + "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" + } + }, + "required": [ + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "buildStatus", + "cloneStatus", + "createdAt", + "description", + "displayName", + "environmentVariableOverrides", + "files", + "id", + "installStatus", + "localFiles", + "prerequisiteIds", + "prerequisiteServiceIds", + "runCommand", + "runStatus", + "serviceId", + "stackName", + "updatedAt" + ], + "description": "Full service view combining definition, config, status, and git state for API responses" + }, + "User": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["serviceId", "cloneStatus", "installStatus", "buildStatus", "runStatus", "updatedAt"], - "additionalProperties": false, - "description": "Runtime status of a service. Managed by the system (ProcessManager, GitWatcher). Never exported. Reset to defaults on import." + "required": ["username", "roles"], + "additionalProperties": false }, "ServiceStateEvent": { "type": "string", @@ -455,6 +633,28 @@ "required": ["id", "serviceId", "processUid", "stream", "line", "createdAt"], "additionalProperties": false }, + "PublicApiToken": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "name": { + "type": "string" + }, + "lastUsedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "username", "name", "createdAt"], + "additionalProperties": false + }, "PrerequisiteType": { "type": "string", "enum": [ @@ -543,6 +743,9 @@ "properties": { "variableName": { "type": "string" + }, + "isSensitive": { + "type": "boolean" } }, "required": ["variableName"], @@ -639,6 +842,9 @@ "properties": { "variableName": { "type": "string" + }, + "isSensitive": { + "type": "boolean" } }, "required": ["variableName"], @@ -724,56 +930,20 @@ "additionalProperties": false, "description": "Represents the result of evaluating a {@link Prerequisite } . Stored in an in-memory store on the service and synced to the frontend via entity sync so that every connected client sees real-time status." }, - "PublicApiToken": { + "GitHubRepository": { "type": "object", "properties": { "id": { - "type": "string" - }, - "username": { - "type": "string" - }, - "name": { - "type": "string" - }, - "lastUsedAt": { - "type": "string" + "type": "string", + "description": "UUID primary key" }, - "createdAt": { - "type": "string" - } - }, - "required": ["id", "username", "name", "createdAt"], - "additionalProperties": false - }, - "StackView": { - "type": "object", - "additionalProperties": false, - "properties": { "stackName": { "type": "string", "description": "FK to {@link StackDefinition.name }" }, - "mainDirectory": { - "type": "string", - "description": "Absolute path to the root directory for all services in this stack" - }, - "environmentVariables": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/EnvironmentVariableValue" - }, - "description": "Stack-level environment variable values, keyed by variable name" - }, - "createdAt": { - "type": "string" - }, - "updatedAt": { - "type": "string" - }, - "name": { + "url": { "type": "string", - "description": "Unique kebab-case identifier for the stack" + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" }, "displayName": { "type": "string", @@ -781,161 +951,43 @@ }, "description": { "type": "string", - "description": "Optional description of what this stack does" + "description": "Optional description" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" } }, - "required": [ - "createdAt", - "description", - "displayName", - "environmentVariables", - "mainDirectory", - "name", - "stackName", - "updatedAt" - ], - "description": "Full stack view combining definition and config for API responses" + "required": ["id", "stackName", "url", "displayName", "description", "createdAt", "updatedAt"], + "additionalProperties": false, + "description": "Shareable GitHub repository definition. Links a git repository to a stack for cloning and pulling. Included in stack exports and shared between installations." }, - "ServiceView": { + "ApiToken": { "type": "object", - "additionalProperties": false, "properties": { - "serviceId": { - "type": "string", - "description": "FK to {@link ServiceDefinition.id }" - }, - "cloneStatus": { - "$ref": "#/definitions/CloneStatus" - }, - "installStatus": { - "$ref": "#/definitions/InstallStatus" - }, - "buildStatus": { - "$ref": "#/definitions/BuildStatus" - }, - "runStatus": { - "$ref": "#/definitions/RunStatus" - }, - "lastClonedAt": { - "type": "string" - }, - "lastInstalledAt": { + "id": { "type": "string" }, - "lastBuiltAt": { + "username": { "type": "string" }, - "lastStartedAt": { + "name": { "type": "string" }, - "lastFetchedAt": { + "tokenHash": { "type": "string" }, - "updatedAt": { + "lastUsedAt": { "type": "string" }, - "autoFetchEnabled": { - "type": "boolean", - "description": "Whether automatic git fetch is enabled" - }, - "autoFetchIntervalMinutes": { - "type": "number", - "description": "Interval in minutes between automatic git fetches" - }, - "autoRestartOnFetch": { - "type": "boolean", - "description": "Whether to automatically restart the service when new commits are fetched" - }, - "environmentVariableOverrides": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/EnvironmentVariableValue" - }, - "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." - }, "createdAt": { "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 }" - }, - "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" } }, - "required": [ - "autoFetchEnabled", - "autoFetchIntervalMinutes", - "autoRestartOnFetch", - "buildStatus", - "cloneStatus", - "createdAt", - "description", - "displayName", - "environmentVariableOverrides", - "files", - "id", - "installStatus", - "prerequisiteIds", - "prerequisiteServiceIds", - "runCommand", - "runStatus", - "serviceId", - "stackName", - "updatedAt" - ], - "description": "Full service view combining definition, config, and status for API responses" + "required": ["id", "username", "name", "tokenHash", "createdAt"], + "additionalProperties": false } } } diff --git a/common/schemas/github-repositories-api.json b/common/schemas/github-repositories-api.json index 7f0e30a..0814bcb 100644 --- a/common/schemas/github-repositories-api.json +++ b/common/schemas/github-repositories-api.json @@ -231,7 +231,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -256,7 +271,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -271,7 +301,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -286,7 +331,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -301,7 +361,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/identity-api.json b/common/schemas/identity-api.json index 4b44e6a..03ed913 100644 --- a/common/schemas/identity-api.json +++ b/common/schemas/identity-api.json @@ -146,7 +146,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -161,7 +176,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -176,7 +206,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -191,7 +236,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -206,7 +266,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -221,7 +296,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -236,7 +326,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/install-api.json b/common/schemas/install-api.json index 71596b3..c46e57d 100644 --- a/common/schemas/install-api.json +++ b/common/schemas/install-api.json @@ -87,7 +87,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -102,7 +117,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -117,7 +147,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -132,7 +177,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -147,7 +207,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -162,7 +237,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -177,7 +267,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/prerequisites-api.json b/common/schemas/prerequisites-api.json index e25f30e..0ecbcab 100644 --- a/common/schemas/prerequisites-api.json +++ b/common/schemas/prerequisites-api.json @@ -113,6 +113,9 @@ "properties": { "variableName": { "type": "string" + }, + "isSensitive": { + "type": "boolean" } }, "required": ["variableName"], @@ -346,7 +349,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -371,7 +389,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -386,7 +419,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -401,7 +449,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -416,7 +479,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/services-api.json b/common/schemas/services-api.json index 348f78a..c623823 100644 --- a/common/schemas/services-api.json +++ b/common/schemas/services-api.json @@ -115,6 +115,13 @@ "$ref": "#/definitions/EnvironmentVariableValue" }, "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." + }, + "localFiles": { + "type": "array", + "items": { + "$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." } }, "required": [ @@ -122,7 +129,8 @@ "autoFetchEnabled", "autoFetchIntervalMinutes", "autoRestartOnFetch", - "environmentVariableOverrides" + "environmentVariableOverrides", + "localFiles" ], "additionalProperties": false }, @@ -137,6 +145,10 @@ "customValue": { "type": "string", "description": "The custom value to use when source is 'custom'" + }, + "isSensitive": { + "type": "boolean", + "description": "Whether this value contains sensitive data (e.g. passwords, tokens). When true, the value is encrypted at rest and masked in API responses. Overrides the prerequisite-level default when set." } }, "required": ["source"], @@ -166,6 +178,13 @@ }, "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." }, + "localFiles": { + "type": "array", + "items": { + "$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." + }, "id": { "type": "string", "description": "UUID primary key" @@ -233,6 +252,7 @@ "environmentVariableOverrides", "files", "id", + "localFiles", "prerequisiteIds", "prerequisiteServiceIds", "runCommand", @@ -260,6 +280,13 @@ "type": "string", "description": "FK to {@link ServiceDefinition.id }" }, + "currentBranch": { + "type": "string" + }, + "commitsBehind": { + "type": "number", + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + }, "cloneStatus": { "$ref": "#/definitions/CloneStatus" }, @@ -309,6 +336,13 @@ }, "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." }, + "localFiles": { + "type": "array", + "items": { + "$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." + }, "createdAt": { "type": "string" }, @@ -383,6 +417,7 @@ "files", "id", "installStatus", + "localFiles", "prerequisiteIds", "prerequisiteServiceIds", "runCommand", @@ -391,7 +426,7 @@ "stackName", "updatedAt" ], - "description": "Full service view combining definition, config, and status for API responses" + "description": "Full service view combining definition, config, status, and git state for API responses" }, "CloneStatus": { "type": "string", @@ -488,6 +523,13 @@ "$ref": "#/definitions/EnvironmentVariableValue" }, "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." + }, + "localFiles": { + "type": "array", + "items": { + "$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." } }, "required": [ @@ -498,6 +540,7 @@ "displayName", "environmentVariableOverrides", "files", + "localFiles", "prerequisiteIds", "prerequisiteServiceIds", "runCommand", @@ -588,6 +631,13 @@ "$ref": "#/definitions/EnvironmentVariableValue" }, "description": "Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults." + }, + "localFiles": { + "type": "array", + "items": { + "$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." } }, "additionalProperties": false @@ -903,6 +953,85 @@ "enum": ["api", "mcp", "auto-fetch", "auto-restart", "system"], "description": "How a state change was triggered. Used to distinguish user actions from automated system behavior." }, + "ServiceBranchesEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "currentBranch": { + "type": "string" + }, + "local": { + "type": "array", + "items": { + "type": "string" + } + }, + "remote": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["currentBranch", "local", "remote"], + "additionalProperties": false + } + }, + "required": ["url", "result"], + "additionalProperties": false + }, + "ServiceCheckoutEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "body": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "required": ["branch"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "serviceId": { + "type": "string" + } + }, + "required": ["success", "serviceId"], + "additionalProperties": false + } + }, + "required": ["url", "body", "result"], + "additionalProperties": false + }, "ServicesApi": { "type": "object", "properties": { @@ -920,9 +1049,18 @@ }, "/services/:id/history": { "$ref": "#/definitions/ServiceHistoryEndpoint" + }, + "/services/:id/branches": { + "$ref": "#/definitions/ServiceBranchesEndpoint" } }, - "required": ["/services", "/services/:id", "/services/:id/logs", "/services/:id/history"], + "required": [ + "/services", + "/services/:id", + "/services/:id/logs", + "/services/:id/history", + "/services/:id/branches" + ], "additionalProperties": false }, "POST": { @@ -957,6 +1095,9 @@ }, "/services/:id/apply-files": { "$ref": "#/definitions/ApplyServiceFilesEndpoint" + }, + "/services/:id/checkout": { + "$ref": "#/definitions/ServiceCheckoutEndpoint" } }, "required": [ @@ -969,7 +1110,8 @@ "/services/:id/pull", "/services/:id/setup", "/services/:id/update", - "/services/:id/apply-files" + "/services/:id/apply-files", + "/services/:id/checkout" ], "additionalProperties": false }, @@ -992,7 +1134,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1020,7 +1177,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1035,7 +1207,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1050,7 +1237,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1065,7 +1267,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -1082,7 +1299,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%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)%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%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" } }, "additionalProperties": false @@ -1095,7 +1312,7 @@ "additionalProperties": false, "description": "Rest endpoint model for getting / querying collections" }, - "FindOptions": { + "FindOptions": { "type": "object", "properties": { "top": { @@ -1185,6 +1402,10 @@ "type": "string", "enum": ["ASC", "DESC"] }, + "localFiles": { + "type": "string", + "enum": ["ASC", "DESC"] + }, "cloneStatus": { "type": "string", "enum": ["ASC", "DESC"] @@ -1220,6 +1441,14 @@ "lastFetchedAt": { "type": "string", "enum": ["ASC", "DESC"] + }, + "currentBranch": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "commitsBehind": { + "type": "string", + "enum": ["ASC", "DESC"] } }, "additionalProperties": false, @@ -1249,6 +1478,7 @@ "autoFetchIntervalMinutes", "autoRestartOnFetch", "environmentVariableOverrides", + "localFiles", "cloneStatus", "installStatus", "buildStatus", @@ -1257,7 +1487,9 @@ "lastInstalledAt", "lastBuiltAt", "lastStartedAt", - "lastFetchedAt" + "lastFetchedAt", + "currentBranch", + "commitsBehind" ] }, "description": "The result set will be limited to these fields" @@ -2066,19 +2298,23 @@ "type": "object", "properties": { "$startsWith": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" }, "$endsWith": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" }, "$like": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" }, "$regex": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" } }, @@ -2088,11 +2324,13 @@ "type": "object", "properties": { "$eq": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" }, "$ne": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" } }, @@ -2104,14 +2342,16 @@ "$in": { "type": "array", "items": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" } }, "$nin": { "type": "array", "items": { - "type": "string", + "type": "object", + "properties": {}, "description": "FK to {@link ServiceDefinition.id }" } } @@ -2306,6 +2546,56 @@ } ] }, + "localFiles": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "$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." + }, + "$ne": { + "type": "array", + "items": { + "$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." + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "$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." + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "$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." + } + } + }, + "additionalProperties": false + } + ] + }, "cloneStatus": { "anyOf": [ { @@ -2753,6 +3043,106 @@ "additionalProperties": false } ] + }, + "currentBranch": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ] + } + } + }, + "additionalProperties": false + } + ] + }, + "commitsBehind": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "number", + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + }, + "$ne": { + "type": "number", + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ], + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "not": {} + } + ], + "description": "Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch." + } + } + }, + "additionalProperties": false + } + ] } } }, @@ -2805,6 +3195,7 @@ "autoFetchIntervalMinutes", "autoRestartOnFetch", "environmentVariableOverrides", + "localFiles", "cloneStatus", "installStatus", "buildStatus", @@ -2813,7 +3204,9 @@ "lastInstalledAt", "lastBuiltAt", "lastStartedAt", - "lastFetchedAt" + "lastFetchedAt", + "currentBranch", + "commitsBehind" ] }, "description": "The list of fields to select" diff --git a/common/schemas/stacks-api.json b/common/schemas/stacks-api.json index d0fd3dc..6139127 100644 --- a/common/schemas/stacks-api.json +++ b/common/schemas/stacks-api.json @@ -42,6 +42,10 @@ "customValue": { "type": "string", "description": "The custom value to use when source is 'custom'" + }, + "isSensitive": { + "type": "boolean", + "description": "Whether this value contains sensitive data (e.g. passwords, tokens). When true, the value is encrypted at rest and masked in API responses. Overrides the prerequisite-level default when set." } }, "required": ["source"], @@ -195,6 +199,28 @@ "additionalProperties": false, "description": "Endpoint model for updating entities" }, + "SecretWarning": { + "type": "object", + "properties": { + "line": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "snippet": { + "type": "string" + }, + "source": { + "type": "string" + }, + "suggestion": { + "type": "string" + } + }, + "required": ["line", "pattern", "snippet", "source"], + "additionalProperties": false + }, "ExportStackEndpoint": { "type": "object", "properties": { @@ -369,6 +395,12 @@ "required": ["id", "stackName", "name", "type", "config", "installationHelp"], "additionalProperties": false } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/SecretWarning" + } } }, "required": ["stack", "services", "repositories", "prerequisites"], @@ -475,6 +507,9 @@ "properties": { "variableName": { "type": "string" + }, + "isSensitive": { + "type": "boolean" } }, "required": ["variableName"], @@ -501,6 +536,12 @@ "properties": { "success": { "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/definitions/SecretWarning" + } } }, "required": ["success"], @@ -692,6 +733,12 @@ "$ref": "#/definitions/EnvironmentVariableValue" } }, + "localFiles": { + "type": "array", + "items": { + "$ref": "#/definitions/ServiceFile" + } + }, "autoFetchEnabled": { "type": "boolean", "description": "Whether automatic git fetch is enabled" @@ -800,7 +847,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -825,7 +887,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -840,7 +917,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -855,7 +947,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -870,7 +977,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/system-api.json b/common/schemas/system-api.json index 591a6b0..af53375 100644 --- a/common/schemas/system-api.json +++ b/common/schemas/system-api.json @@ -39,7 +39,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -64,7 +79,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -79,7 +109,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -94,7 +139,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -109,7 +169,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -124,7 +199,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -139,7 +229,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -154,7 +259,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/schemas/tokens-api.json b/common/schemas/tokens-api.json index b781e5f..5f768cf 100644 --- a/common/schemas/tokens-api.json +++ b/common/schemas/tokens-api.json @@ -85,7 +85,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -100,7 +115,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -125,7 +155,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -140,7 +185,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -155,7 +215,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false @@ -170,7 +245,22 @@ "url": {}, "query": {}, "body": {}, - "headers": {} + "headers": {}, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "deprecated": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } }, "required": ["result"], "additionalProperties": false diff --git a/common/src/apis/services.ts b/common/src/apis/services.ts index 5648c09..b6b8aff 100644 --- a/common/src/apis/services.ts +++ b/common/src/apis/services.ts @@ -42,12 +42,24 @@ export type ServiceHistoryEndpoint = { result: { entries: ServiceStateHistory[] } } +export type ServiceBranchesEndpoint = { + url: { id: string } + result: { currentBranch: string; local: string[]; remote: string[] } +} + +export type ServiceCheckoutEndpoint = { + url: { id: string } + body: { branch: string } + result: { success: boolean; serviceId: string } +} + export interface ServicesApi extends RestApi { GET: { '/services': GetCollectionEndpoint '/services/:id': GetEntityEndpoint '/services/:id/logs': ServiceLogsEndpoint '/services/:id/history': ServiceHistoryEndpoint + '/services/:id/branches': ServiceBranchesEndpoint } POST: { '/services': PostServiceEndpoint @@ -60,6 +72,7 @@ export interface ServicesApi extends RestApi { '/services/:id/setup': ServiceActionEndpoint '/services/:id/update': ServiceActionEndpoint '/services/:id/apply-files': ApplyServiceFilesEndpoint + '/services/:id/checkout': ServiceCheckoutEndpoint } PATCH: { '/services/:id': PatchServiceEndpoint diff --git a/common/src/apis/stacks.ts b/common/src/apis/stacks.ts index d4a648e..85b264f 100644 --- a/common/src/apis/stacks.ts +++ b/common/src/apis/stacks.ts @@ -4,6 +4,7 @@ import type { EnvironmentVariableValue } from '../models/environment-variable-va 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 { StackConfig } from '../models/stack-config.js' import type { StackDefinition } from '../models/stack-definition.js' @@ -19,6 +20,14 @@ type ShareableServiceDefinition = Omit type ShareablePrerequisite = Omit +export type SecretWarning = { + line: number + pattern: string + snippet: string + source: string + suggestion?: string +} + export type ExportStackEndpoint = { url: { id: string } result: { @@ -26,11 +35,12 @@ export type ExportStackEndpoint = { services: ShareableServiceDefinition[] repositories: ShareableGitHubRepository[] prerequisites: ShareablePrerequisite[] + warnings?: SecretWarning[] } } export type ImportStackEndpoint = { - result: { success: boolean } + result: { success: boolean; warnings?: SecretWarning[] } body: { stack: ShareableStackDefinition services: ShareableServiceDefinition[] @@ -43,6 +53,7 @@ export type ImportStackEndpoint = { string, Partial> & { environmentVariableOverrides?: Record + localFiles?: ServiceFile[] } > } diff --git a/common/src/index.ts b/common/src/index.ts index ff71825..4f8d99c 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -1,4 +1,3 @@ export * from './models/index.js' export * from './apis/index.js' -export * from './websocket/index.js' export * from './utils/service-path-utils.js' diff --git a/common/src/models/environment-variable-value.ts b/common/src/models/environment-variable-value.ts index d238433..fe41dce 100644 --- a/common/src/models/environment-variable-value.ts +++ b/common/src/models/environment-variable-value.ts @@ -8,4 +8,10 @@ export type EnvironmentVariableValue = { source: 'inherit' | 'custom' /** The custom value to use when source is 'custom' */ customValue?: string + /** + * Whether this value contains sensitive data (e.g. passwords, tokens). + * When true, the value is encrypted at rest and masked in API responses. + * Overrides the prerequisite-level default when set. + */ + isSensitive?: boolean } diff --git a/common/src/models/index.ts b/common/src/models/index.ts index 0530dc7..6b9ced6 100644 --- a/common/src/models/index.ts +++ b/common/src/models/index.ts @@ -5,6 +5,7 @@ export * from './stack-config.js' export * from './service-definition.js' export * from './service-config.js' export * from './service-status.js' +export * from './service-git-status.js' export * from './service-state-history.js' export * from './service-log-entry.js' export * from './github-repository.js' diff --git a/common/src/models/prerequisite.ts b/common/src/models/prerequisite.ts index 200bbc1..eece2bd 100644 --- a/common/src/models/prerequisite.ts +++ b/common/src/models/prerequisite.ts @@ -23,7 +23,7 @@ export type PrerequisiteConfigMap = { 'nuget-feed': { feedUrl: string; feedName?: string } git: Record 'github-cli': Record - 'env-variable': { variableName: string } + 'env-variable': { variableName: string; isSensitive?: boolean } 'custom-script': { script: string } } diff --git a/common/src/models/service-config.ts b/common/src/models/service-config.ts index 1016239..b0524e3 100644 --- a/common/src/models/service-config.ts +++ b/common/src/models/service-config.ts @@ -1,4 +1,5 @@ import type { EnvironmentVariableValue } from './environment-variable-value.js' +import type { ServiceFile } from './service-definition.js' /** * User-specific service configuration. @@ -22,6 +23,13 @@ export class ServiceConfig { /** Per-service environment variable overrides, keyed by variable name. Overrides stack-level defaults. */ environmentVariableOverrides: Record = {} + /** + * 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. + */ + localFiles: ServiceFile[] = [] + createdAt!: string updatedAt!: string } diff --git a/common/src/models/service-git-status.ts b/common/src/models/service-git-status.ts new file mode 100644 index 0000000..5c3efa6 --- /dev/null +++ b/common/src/models/service-git-status.ts @@ -0,0 +1,14 @@ +/** + * In-memory git state for a service. + * Derived from the filesystem (`.git/HEAD`) and periodic remote checks, never persisted to DB. + * @see ServiceStatus for the persisted runtime status + */ +export class ServiceGitStatus { + /** FK to {@link import('./service-definition.js').ServiceDefinition.id} */ + serviceId!: string + + currentBranch?: string + + /** Number of commits the local branch is behind `origin/`. Updated by GitWatcher after each fetch. */ + commitsBehind?: number +} diff --git a/common/src/models/service-status.ts b/common/src/models/service-status.ts index f7541a1..ed810f1 100644 --- a/common/src/models/service-status.ts +++ b/common/src/models/service-status.ts @@ -24,6 +24,5 @@ export class ServiceStatus { lastBuiltAt?: string lastStartedAt?: string lastFetchedAt?: string - updatedAt!: string } diff --git a/common/src/models/views.ts b/common/src/models/views.ts index ab6a3b5..7e6a7f9 100644 --- a/common/src/models/views.ts +++ b/common/src/models/views.ts @@ -2,10 +2,11 @@ import type { StackConfig } from './stack-config.js' import type { StackDefinition } from './stack-definition.js' import type { ServiceConfig } from './service-config.js' import type { ServiceDefinition } from './service-definition.js' +import type { ServiceGitStatus } from './service-git-status.js' 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, and status for API responses */ -export type ServiceView = ServiceDefinition & ServiceConfig & ServiceStatus +/** Full service view combining definition, config, status, and git state for API responses */ +export type ServiceView = ServiceDefinition & ServiceConfig & ServiceStatus & ServiceGitStatus diff --git a/common/src/websocket/index.ts b/common/src/websocket/index.ts deleted file mode 100644 index c87bd5b..0000000 --- a/common/src/websocket/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { CloneStatus, InstallStatus, BuildStatus, RunStatus } from '../models/service-status.js' - -export type WebsocketMessage = - | { - type: 'service-status-changed' - serviceId: string - cloneStatus: CloneStatus - installStatus: InstallStatus - buildStatus: BuildStatus - runStatus: RunStatus - } - | { - type: 'git-branches-changed' - serviceId: string - newBranches: string[] - } - | { - type: 'dependency-check-result' - dependencyId: string - satisfied: boolean - output: string - } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..946c2f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: stackcraft + POSTGRES_PASSWORD: stackcraft + POSTGRES_DB: stackcraft + ports: + - '5433:5432' + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/e2e/dogfooding.spec.ts b/e2e/dogfooding.spec.ts index d5b878c..973b7a3 100644 --- a/e2e/dogfooding.spec.ts +++ b/e2e/dogfooding.spec.ts @@ -8,7 +8,31 @@ 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 TIMETest Stack - ${browserName} - ${uuid}` + const displayName = `E2E DOG FOODING Stack - ${browserName} - ${uuid}` + const description = ` +### 🐶🦴 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 + - Creating a service that uses the StackCraft GitHub repository + - Cloning, installing, building and running the service + +### Test Steps + + 1. Create a stack + 2. Create a service that uses 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! + + ` const workingDirectory = `/tmp/e2e-dog-fooding-time-${uuid}` @@ -21,7 +45,7 @@ test('DOG FOODING TIME - Create a service that uses the StackCraft GitHub reposi 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('textarea[name="description"]').fill(description) await page.locator('input[name="mainDirectory"]').fill('/tmp/e2e-test') await page.locator('button', { hasText: 'Create' }).click() @@ -31,7 +55,9 @@ test('DOG FOODING TIME - Create a service that uses the StackCraft GitHub reposi await expect(page.getByTestId('page-header-title')).toContainText(displayName) - // Create the service with the StackCraft GitHub repository + // 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() @@ -69,17 +95,17 @@ test('DOG FOODING TIME - Create a service that uses the StackCraft GitHub reposi await page.locator('button', { hasText: 'View Service' }).click() await expect(page.locator('shade-service-detail')).toBeVisible() - // Start the service - await page.locator('shade-service-detail button', { hasText: 'Start' }).click() + // 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 logs - await page.getByTestId('page-header-actions').getByRole('button', { name: 'Logs' }).click() - await expect(page.locator('shade-service-logs')).toBeVisible() + // 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 log entries appear - await expect(page.locator('shade-log-viewer')).toBeVisible() - await expect(page.locator('shade-log-viewer')).not.toContainText('No log output yet.') + // 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.') }) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index c457f4b..37ea60e 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -34,7 +34,9 @@ test.describe.serial('App Flow', () => { await expect(page.getByTestId('page-header-title')).toContainText(displayName) - // Create an example "Hello World" service + // 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() @@ -43,10 +45,14 @@ test.describe.serial('App Flow', () => { await page.locator('input[name="runCommand"]').fill('echo hello') await page.locator('button', { hasText: 'Create' }).click() - await expect(page.locator('shade-dashboard')).toBeVisible() - await expect(page.getByTestId('page-header-title')).toContainText(displayName) + 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 }) - // Create a repository - FuryStack (https://github.com/furystack/furystack) + // 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() @@ -54,9 +60,11 @@ test.describe.serial('App Flow', () => { await page.locator('input[name="url"]').fill('https://github.com/furystack/furystack') await page.locator('button', { hasText: 'Add' }).click() - await expect(page.locator('shade-dashboard')).toBeVisible() - await expect(page.getByTestId('page-header-title')).toContainText(displayName) + 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() @@ -65,6 +73,6 @@ test.describe.serial('App Flow', () => { await page.locator('input[name="runCommand"]').fill('echo hello') await page.locator('button', { hasText: 'Create' }).click() - await expect(page.locator('shade-dashboard')).toBeVisible() + await expect(page.locator('shade-services-list')).toBeVisible() }) }) diff --git a/frontend/src/components/app-routes.tsx b/frontend/src/components/app-routes.tsx index 4bd90c0..8e568d7 100644 --- a/frontend/src/components/app-routes.tsx +++ b/frontend/src/components/app-routes.tsx @@ -3,11 +3,14 @@ import type { MatchResult } from 'path-to-regexp' import { Dashboard } from '../pages/dashboard/index.js' import { ExportStack } from '../pages/import-export/export-stack.js' import { ImportStack } from '../pages/import-export/import-stack.js' +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 { 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' @@ -18,55 +21,88 @@ export const appRoutes = { '/': { component: () => , }, - '/services/create/:stackName': { + '/settings': { + component: () => , + }, + '/stacks/create': { + component: () => , + }, + '/stacks/import': { + component: () => , + }, + '/stacks/:stackName': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, - '/services/wizard/:stackName': { + '/stacks/:stackName/edit': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, - '/services/:id/logs/:processUid': { - component: ({ match }: { match: MatchResult<{ id: string; processUid: string }> }) => ( - + '/stacks/:stackName/export': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + ), }, - '/services/:id/logs': { - component: ({ match }: { match: MatchResult<{ id: string }> }) => , + '/stacks/:stackName/setup': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + + ), }, - '/services/:id': { - component: ({ match }: { match: MatchResult<{ id: string }> }) => , + '/stacks/:stackName/services': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + + ), }, - '/repositories/create/:stackName': { + '/stacks/:stackName/services/create': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, - '/repositories/:id': { - component: ({ match }: { match: MatchResult<{ id: string }> }) => , + '/stacks/:stackName/services/wizard': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + + ), }, - '/settings': { - component: () => , + '/stacks/:stackName/services/:serviceId/logs/:processUid': { + component: ({ match }: { match: MatchResult<{ stackName: string; serviceId: string; processUid: string }> }) => ( + + ), }, - '/stacks/create': { - component: () => , + '/stacks/:stackName/services/:serviceId/logs': { + component: ({ match }: { match: MatchResult<{ stackName: string; serviceId: string }> }) => ( + + ), }, - '/stacks/import': { - component: () => , + '/stacks/:stackName/services/:serviceId': { + component: ({ match }: { match: MatchResult<{ stackName: string; serviceId: string }> }) => ( + + ), }, - '/stacks/:name/setup': { - component: ({ match }: { match: MatchResult<{ name: string }> }) => , + '/stacks/:stackName/repositories': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + + ), }, - '/stacks/:name/edit': { - component: ({ match }: { match: MatchResult<{ name: string }> }) => , + '/stacks/:stackName/repositories/create': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + + ), }, - '/stacks/:name': { - component: ({ match }: { match: MatchResult<{ name: string }> }) => , + '/stacks/:stackName/repositories/:repositoryId': { + component: ({ match }: { match: MatchResult<{ stackName: string; repositoryId: string }> }) => ( + + ), }, - '/stacks/:name/export': { - component: ({ match }: { match: MatchResult<{ name: string }> }) => , + '/stacks/:stackName/prerequisites': { + component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( + + ), }, } as const satisfies Record> diff --git a/frontend/src/components/branch-selector.tsx b/frontend/src/components/branch-selector.tsx new file mode 100644 index 0000000..1b75bdd --- /dev/null +++ b/frontend/src/components/branch-selector.tsx @@ -0,0 +1,229 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Button, Chip, cssVariableTheme, Icon, icons, NotyService } from '@furystack/shades-common-components' + +import { ServicesApiClient } from '../services/api-clients/services-api-client.js' + +type BranchSelectorProps = { + serviceId: string + currentBranch?: string + isCloned: boolean +} + +export const BranchSelector = Shade({ + customElementName: 'shade-branch-selector', + render: ({ props, injector, useState }) => { + 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({ + method: 'GET', + action: '/services/:id/branches', + url: { id: serviceId }, + }) + setBranches({ local: result.result.local, remote: result.result.remote }) + } catch { + 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) + } + } + + const toggleDropdown = () => { + const willOpen = !isOpen + setIsOpen(willOpen) + if (willOpen) { + void loadBranches() + } + } + + 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 filteredBranches = filter + ? allBranches.filter((b) => b.name.toLowerCase().includes(filter.toLowerCase())) + : allBranches + + const branchLabel = currentBranch ?? 'Select branch...' + + 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/breadcrumbs.tsx b/frontend/src/components/breadcrumbs.tsx new file mode 100644 index 0000000..14717da --- /dev/null +++ b/frontend/src/components/breadcrumbs.tsx @@ -0,0 +1,187 @@ +import { useEntitySync } from '@furystack/entity-sync-client' +import { createComponent, LocationService, Shade } from '@furystack/shades' +import { cssVariableTheme } from '@furystack/shades-common-components' +import { GitHubRepository as GitHubRepositoryModel, ServiceDefinition, StackDefinition } from 'common' + +import { StackCraftNestedRouteLink } from './app-routes.js' + +type BreadcrumbSegment = { + label: string + href?: Parameters[0]['href'] + params?: Record +} + +const parseBreadcrumbs = ( + pathname: string, +): { segments: BreadcrumbSegment[]; stackName?: string; serviceId?: string; repositoryId?: string } => { + const segments: BreadcrumbSegment[] = [] + const parts = pathname.split('/').filter(Boolean) + if (parts[0] !== 'stacks' || parts.length < 2) return { segments } + + let serviceId: string | undefined + let repositoryId: string | undefined + + const stackName = parts[1] + if (stackName === 'create' || stackName === 'import') return { segments } + + segments.push({ + label: stackName, + href: '/stacks/:stackName', + params: { stackName }, + }) + + const subSection = parts[2] + if (!subSection) return { segments, stackName } + + if (subSection === 'services') { + segments.push({ + label: 'Services', + href: '/stacks/:stackName/services', + params: { stackName }, + }) + + serviceId = parts[3] + if (serviceId && serviceId !== 'create' && serviceId !== 'wizard') { + segments.push({ + label: serviceId, + href: '/stacks/:stackName/services/:serviceId', + params: { stackName, serviceId }, + }) + + const logSection = parts[4] + if (logSection === 'logs') { + const processUid = parts[5] + if (processUid) { + segments.push({ + label: 'Logs', + href: '/stacks/:stackName/services/:serviceId/logs', + params: { stackName, serviceId }, + }) + segments.push({ label: `${processUid.slice(0, 8)}…` }) + } else { + segments.push({ label: 'Logs' }) + } + } + } else if (serviceId === 'create') { + segments.push({ label: 'Create' }) + } else if (serviceId === 'wizard') { + segments.push({ label: 'Create' }) + } + } else if (subSection === 'repositories') { + segments.push({ + label: 'Repositories', + href: '/stacks/:stackName/repositories', + params: { stackName }, + }) + + repositoryId = parts[3] + if (repositoryId && repositoryId !== 'create') { + segments.push({ label: repositoryId }) + } else if (repositoryId === 'create') { + segments.push({ label: 'Add' }) + } + } else if (subSection === 'prerequisites') { + segments.push({ label: 'Prerequisites' }) + } else if (subSection === 'edit') { + segments.push({ label: 'Edit' }) + } else if (subSection === 'export') { + segments.push({ label: 'Export' }) + } else if (subSection === 'setup') { + segments.push({ label: 'Setup' }) + } + + return { segments, stackName, serviceId, repositoryId } +} + +type EntityNameResolverProps = { + stackName?: string + serviceId?: string + repositoryId?: string + segments: BreadcrumbSegment[] +} + +const EntityNameResolver = Shade({ + customElementName: 'shade-entity-name-resolver', + css: { + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '13px', + color: cssVariableTheme.text.secondary, + flexWrap: 'wrap', + '& a': { + color: cssVariableTheme.text.secondary, + textDecoration: 'none', + transition: `color ${cssVariableTheme.transitions.duration.fast} ease`, + }, + '& a:hover': { + color: cssVariableTheme.text.primary, + }, + '& .breadcrumb-current': { + color: cssVariableTheme.text.primary, + fontWeight: cssVariableTheme.typography.fontWeight.semibold, + }, + '& .breadcrumb-separator': { + opacity: '0.4', + fontSize: '11px', + }, + }, + render: (options) => { + const { props } = options + const resolved = [...props.segments] + + const stackState = props.stackName ? useEntitySync(options, StackDefinition, props.stackName) : undefined + const serviceState = props.serviceId ? useEntitySync(options, ServiceDefinition, props.serviceId) : undefined + const repoState = props.repositoryId ? useEntitySync(options, GitHubRepositoryModel, props.repositoryId) : undefined + + if (stackState?.status === 'synced' && stackState.data) { + const stackSeg = resolved.find((s) => s.label === props.stackName) + if (stackSeg) stackSeg.label = stackState.data.displayName + } + + if (serviceState?.status === 'synced' && serviceState.data) { + const svcSeg = resolved.find((s) => s.label === props.serviceId) + if (svcSeg) svcSeg.label = serviceState.data.displayName + } + + if (repoState?.status === 'synced' && repoState.data) { + const repoSeg = resolved.find((s) => s.label === props.repositoryId) + if (repoSeg) repoSeg.label = repoState.data.displayName + } + + return ( + + ) + }, +}) + +export const Breadcrumbs = Shade({ + customElementName: 'shade-breadcrumbs', + render: ({ injector, useObservable }) => { + const [currentUrl] = useObservable('locationChange', injector.getInstance(LocationService).onLocationPathChanged) + + const { segments, stackName, serviceId, repositoryId } = parseBreadcrumbs(currentUrl) + + if (segments.length === 0) return + + return ( + + ) + }, +}) diff --git a/frontend/src/components/entity-forms/github-repo-form.tsx b/frontend/src/components/entity-forms/github-repo-form.tsx index 68e6f5b..d382dd1 100644 --- a/frontend/src/components/entity-forms/github-repo-form.tsx +++ b/frontend/src/components/entity-forms/github-repo-form.tsx @@ -1,5 +1,5 @@ import { createComponent, Shade } from '@furystack/shades' -import { Button, Form, Icon, icons, Input, MarkdownInput } from '@furystack/shades-common-components' +import { Button, Form, Icon, icons, Input, MarkdownEditor } from '@furystack/shades-common-components' import type { GitHubRepository } from 'common' import { StackCraftNestedRouteLink } from '../app-routes.js' @@ -57,7 +57,7 @@ export const GitHubRepoForm = Shade({ required value={props.initial?.displayName ?? ''} /> - +
{props.cancelHref ? ( diff --git a/frontend/src/components/entity-forms/prerequisite-form.tsx b/frontend/src/components/entity-forms/prerequisite-form.tsx index cdfd7ca..07d038b 100644 --- a/frontend/src/components/entity-forms/prerequisite-form.tsx +++ b/frontend/src/components/entity-forms/prerequisite-form.tsx @@ -1,5 +1,5 @@ import { createComponent, Shade } from '@furystack/shades' -import { Button, Form, Icon, icons, Input, MarkdownInput, Select } from '@furystack/shades-common-components' +import { Button, Checkbox, Form, Icon, icons, Input, MarkdownEditor, Select } from '@furystack/shades-common-components' import type { Prerequisite, PrerequisiteConfig, PrerequisiteType } from 'common' type PrerequisiteFormPayload = { @@ -10,6 +10,7 @@ type PrerequisiteFormPayload = { feedUrl?: string feedName?: string variableName?: string + isSensitive?: string script?: string installationHelp?: string } @@ -49,7 +50,10 @@ const buildConfig = (data: PrerequisiteFormPayload): PrerequisiteConfig => { case 'github-cli': return {} as PrerequisiteConfig case 'env-variable': - return { variableName: data.variableName! } as PrerequisiteConfig + return { + variableName: data.variableName!, + ...(data.isSensitive === 'on' ? { isSensitive: true } : {}), + } as PrerequisiteConfig case 'custom-script': return { script: data.script! } as PrerequisiteConfig default: @@ -165,14 +169,21 @@ export const PrerequisiteForm = Shade({ )} {selectedType === 'env-variable' && ( - 'The environment variable name to check (e.g., "GITHUB_TOKEN")'} - /> +
+ 'The environment variable name to check (e.g., "GITHUB_TOKEN")'} + /> + +
)} {selectedType === 'custom-script' && ( @@ -186,7 +197,7 @@ export const PrerequisiteForm = Shade({ /> )} - ({ 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) @@ -104,6 +105,7 @@ export const ServiceForm = Shade({ prerequisiteIds: selectedPrereqIds, prerequisiteServiceIds: selectedPrereqServiceIds, files: sharedFiles.filter((f) => f.relativePath.trim().length > 0), + localFiles: localFiles.filter((f) => f.relativePath.trim().length > 0), }) } disableOnSubmit @@ -119,7 +121,7 @@ export const ServiceForm = Shade({ required value={props.initial?.displayName ?? ''} /> - ({

Shared Files

-

+

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

{sharedFiles.length > 0 ? ( @@ -193,7 +195,7 @@ export const ServiceForm = Shade({ {sharedFiles.map((file, index) => (
({ flex: '1', padding: '6px 10px', borderRadius: '4px', - border: '1px solid rgba(255,255,255,0.2)', + border: `1px solid ${cssVariableTheme.divider}`, background: 'transparent', color: 'inherit', fontFamily: 'monospace', - fontSize: '13px', + fontSize: cssVariableTheme.typography.fontSize.sm, }} />
+
+
+

Local Files

+ +
+

+ Per-installation secret files. Encrypted at rest and never included in stack exports. +

+ {localFiles.length > 0 ? ( +
+ {localFiles.map((file, index) => { + const isOverridingShared = sharedFiles.some((sf) => sf.relativePath === file.relativePath) + return ( +
+
+ { + const updated = [...localFiles] + updated[index] = { ...updated[index], relativePath: (ev.target as HTMLInputElement).value } + setLocalFiles(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, + }} + /> + {isOverridingShared ? ( + + Overrides shared + + ) : null} + +
+