diff --git a/.cursor/rules/REST_SERVICE.mdc b/.cursor/rules/REST_SERVICE.mdc index f15c84c..1573228 100644 --- a/.cursor/rules/REST_SERVICE.mdc +++ b/.cursor/rules/REST_SERVICE.mdc @@ -237,6 +237,74 @@ export const authorizedDataSet: Partial> = { } ``` +## Data Access + +### NEVER Use `getStoreManager()` or `StoreManager` Directly + +All data access **must** go through the Repository layer (`getRepository()`) using DataSets. Direct `StoreManager` / `getStoreManager()` usage bypasses authorization and is **forbidden**. + +```typescript +// ❌ FORBIDDEN - bypasses authorization +import { getStoreManager } from '@furystack/core' +const sm = getStoreManager(injector) +const users = await sm.getStoreFor(User, 'username').find({}) + +// ❌ FORBIDDEN - direct StoreManager injection +@Injected(StoreManager) +declare private storeManager: StoreManager + +// ✅ Good - use Repository DataSets +import { getRepository } from '@furystack/repository' +const repository = getRepository(injector) +const users = await repository.getDataSetFor(User, 'username').find(injector, {}) +``` + +### REST Action Handlers + +REST action handlers receive a scoped `injector` with an `IdentityContext` already set up per-request. Pass it directly to DataSet methods: + +```typescript +const MyAction: RequestAction = async ({ injector }) => { + const repository = getRepository(injector) + const items = await repository.getDataSetFor(MyModel, 'id').find(injector, {}) + return JsonResult({ items }) +} +``` + +### Elevated Context for Background Operations + +Background services, middleware, and startup code have no HTTP request context. Use `useSystemIdentityContext()` from `@furystack/core` to create a child injector with system-level privileges: + +```typescript +import { useSystemIdentityContext } from '@furystack/core' +import { getRepository } from '@furystack/repository' +import { usingAsync } from '@furystack/utils' + +// One-off operation (automatic cleanup with usingAsync) +await usingAsync(useSystemIdentityContext({ injector }), async (elevated) => { + const repository = getRepository(elevated) + const items = await repository.getDataSetFor(MyModel, 'id').find(elevated, {}) + await repository.getDataSetFor(MyModel, 'id').update(elevated, id, changes) +}) + +// Singleton services (cache and dispose with service) +@Injectable({ lifetime: 'singleton' }) +export class MyService { + private elevatedInjector?: Injector + + private getElevatedInjector(): Injector { + if (!this.elevatedInjector) { + this.elevatedInjector = useSystemIdentityContext({ injector: getInjectorReference(this) }) + } + return this.elevatedInjector + } + + public async [Symbol.asyncDispose]() { + await this.elevatedInjector?.[Symbol.asyncDispose]() + } +} +``` + ## Store Types ### FileSystemStore @@ -316,26 +384,25 @@ void attachShutdownHandler(injector) ### Seed Script -Create a seed script for initial data: +Create a seed script for initial data using elevated context: ```typescript // service/src/seed.ts -import { StoreManager } from '@furystack/core' +import { useSystemIdentityContext } from '@furystack/core' +import { getRepository } from '@furystack/repository' +import { usingAsync } from '@furystack/utils' import { PasswordAuthenticator, PasswordCredential } from '@furystack/security' import { User } from 'common' import { injector } from './config.js' export const seed = async (i: Injector): Promise => { - const sm = i.getInstance(StoreManager) - const userStore = sm.getStoreFor(User, 'username') - const pwcStore = sm.getStoreFor(PasswordCredential, 'userName') - - // Create default user credentials - const cred = await i.getInstance(PasswordAuthenticator).hasher.createCredential('testuser', 'password') + await usingAsync(useSystemIdentityContext({ injector: i }), async (elevated) => { + const repository = getRepository(elevated) + const cred = await i.getInstance(PasswordAuthenticator).hasher.createCredential('testuser', 'password') - // Save to stores - await pwcStore.add(cred) - await userStore.add({ username: 'testuser', roles: [] }) + await repository.getDataSetFor(PasswordCredential, 'userName').add(elevated, cred) + await repository.getDataSetFor(User, 'username').add(elevated, { username: 'testuser', roles: [] }) + }) } await seed(injector) @@ -375,16 +442,21 @@ useRestService({ 3. **Validate requests** - Use `Validate` wrapper with JSON schemas 4. **Configure stores** - `FileSystemStore` for persistence, `InMemoryStore` for sessions 5. **Handle authorization** - Define authorization functions for data sets -6. **Graceful shutdown** - Implement proper cleanup with `Symbol.asyncDispose` -7. **CORS setup** - Configure for frontend origins +6. **NEVER use `getStoreManager()` or `StoreManager` directly** - Always use `getRepository().getDataSetFor()` for data access +7. **Use `useSystemIdentityContext()` from `@furystack/core`** for background services, middleware, and startup operations that lack an HTTP request context +8. **Graceful shutdown** - Implement proper cleanup with `Symbol.asyncDispose` +9. **CORS setup** - Configure for frontend origins **Service Checklist:** - [ ] API types defined in `common` package - [ ] JSON schemas generated for validation - [ ] Stores configured for all models +- [ ] DataSets created for all models via `getRepository().createDataSet()` - [ ] Authentication set up with `useHttpAuthentication` - [ ] Authorization functions defined +- [ ] No `getStoreManager()` or `StoreManager` usage — all data access via Repository +- [ ] Background services use `useSystemIdentityContext()` for data access - [ ] CORS configured for frontend - [ ] Graceful shutdown handler attached - [ ] Error handling for startup failures diff --git a/.gitignore b/.gitignore index 73d183a..7858d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,22 @@ dist *.tsbuildinfo frontend/bundle/js +# TypeScript compilation artifacts for config/spec files +e2e/*.js +e2e/*.d.ts +e2e/*.js.map +frontend/vite.config.js +frontend/vite.config.d.ts +frontend/vite.config.js.map +playwright.config.js +playwright.config.d.ts +playwright.config.js.map +vitest.config.mjs +vitest.config.d.mts +vitest.config.mjs.map + service/data.sqlite +service/data/ testresults .pnp.* diff --git a/.yarn/versions/0de640ed.yml b/.yarn/versions/0de640ed.yml new file mode 100644 index 0000000..4b638da --- /dev/null +++ b/.yarn/versions/0de640ed.yml @@ -0,0 +1,5 @@ +releases: + common: patch + frontend: patch + service: patch + stack-craft: patch diff --git a/common/package.json b/common/package.json index 5532994..5f33d85 100644 --- a/common/package.json +++ b/common/package.json @@ -25,11 +25,12 @@ "create-schemas": "node ./dist/bin/create-schemas.js" }, "devDependencies": { - "@types/node": "^25.2.3", + "@types/node": "^25.3.0", "ts-json-schema-generator": "^2.5.0", "vitest": "^4.0.18" }, "dependencies": { - "@furystack/rest": "^8.0.36" + "@furystack/core": "^15.1.0", + "@furystack/rest": "^8.0.37" } } diff --git a/common/schemas/dependencies-api.json b/common/schemas/dependencies-api.json new file mode 100644 index 0000000..1efd04d --- /dev/null +++ b/common/schemas/dependencies-api.json @@ -0,0 +1,908 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "DependencyWritableFields": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "required": ["id", "stackName", "name", "checkCommand", "installationHelp"], + "additionalProperties": false + }, + "PostDependencyEndpoint": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/Dependency" + }, + "body": { + "$ref": "#/definitions/WithOptionalId%3CDependencyWritableFields%2C%22id%22%3E" + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "Dependency": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "stackName", "name", "checkCommand", "installationHelp", "createdAt", "updatedAt"], + "additionalProperties": false, + "description": "Shareable dependency definition. Describes an external prerequisite (e.g. Node.js, Docker) that services may require. Included in stack exports and shared between installations." + }, + "WithOptionalId": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "required": ["checkCommand", "installationHelp", "name", "stackName"] + }, + "PatchDependencyEndpoint": { + "$ref": "#/definitions/PatchEndpoint%3CDependencyWritableFields%2C%22id%22%3E" + }, + "PatchEndpoint": { + "type": "object", + "properties": { + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["body", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for updating entities" + }, + "CheckDependencyEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "satisfied": { + "type": "boolean" + }, + "output": { + "type": "string" + } + }, + "required": ["satisfied", "output"], + "additionalProperties": false + } + }, + "required": ["url", "result"], + "additionalProperties": false + }, + "DependenciesApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/dependencies": { + "$ref": "#/definitions/GetCollectionEndpoint%3CDependency%3E" + }, + "/dependencies/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CDependency%2C%22id%22%3E" + } + }, + "required": ["/dependencies", "/dependencies/:id"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/dependencies": { + "$ref": "#/definitions/PostDependencyEndpoint" + }, + "/dependencies/:id/check": { + "$ref": "#/definitions/CheckDependencyEndpoint" + } + }, + "required": ["/dependencies", "/dependencies/:id/check"], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "properties": { + "/dependencies/:id": { + "$ref": "#/definitions/PatchDependencyEndpoint" + } + }, + "required": ["/dependencies/:id"], + "additionalProperties": false + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "properties": { + "/dependencies/:id": { + "$ref": "#/definitions/DeleteEndpoint%3CDependency%2C%22id%22%3E" + } + }, + "required": ["/dependencies/:id"], + "additionalProperties": false + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST", "PATCH", "DELETE"], + "additionalProperties": false + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CDependency%2C(%22id%22%7C%22stackName%22%7C%22name%22%7C%22checkCommand%22%7C%22installationHelp%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CDependency%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "stackName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "name": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "checkCommand": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "installationHelp": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["id", "stackName", "name", "checkCommand", "installationHelp", "createdAt", "updatedAt"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CDependency%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CDependency%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CDependency%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CDependency%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CDependency%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "UUID primary key" + }, + "$endsWith": { + "type": "string", + "description": "UUID primary key" + }, + "$like": { + "type": "string", + "description": "UUID primary key" + }, + "$regex": { + "type": "string", + "description": "UUID primary key" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "UUID primary key" + }, + "$ne": { + "type": "string", + "description": "UUID primary key" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "UUID primary key" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "UUID primary key" + } + } + }, + "additionalProperties": false + } + ] + }, + "stackName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$endsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$like": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$regex": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$ne": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + } + }, + "additionalProperties": false + } + ] + }, + "name": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "$endsWith": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "$like": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "$regex": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "$ne": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "checkCommand": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "$endsWith": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "$like": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "$regex": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "$ne": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "installationHelp": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Help text shown when the dependency check fails" + }, + "$endsWith": { + "type": "string", + "description": "Help text shown when the dependency check fails" + }, + "$like": { + "type": "string", + "description": "Help text shown when the dependency check fails" + }, + "$regex": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Help text shown when the dependency check fails" + }, + "$ne": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/Dependency" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["id", "stackName", "name", "checkCommand", "installationHelp", "createdAt", "updatedAt"] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/Dependency" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, + "DeleteEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["url", "result"], + "additionalProperties": false, + "description": "Endpoint model for deleting entities" + } + } +} diff --git a/common/schemas/entities.json b/common/schemas/entities.json index ec29351..850b838 100644 --- a/common/schemas/entities.json +++ b/common/schemas/entities.json @@ -1,7 +1,99 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/User", "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 + }, + "Dependency": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "stackName", "name", "checkCommand", "installationHelp", "createdAt", "updatedAt"], + "additionalProperties": false, + "description": "Shareable dependency definition. Describes an external prerequisite (e.g. Node.js, Docker) that services may require. Included in stack exports and shared between installations." + }, + "GitHubRepository": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "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." + }, "User": { "type": "object", "properties": { @@ -17,6 +109,478 @@ }, "required": ["username", "roles"], "additionalProperties": false + }, + "StackDefinition": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["name", "displayName", "description", "createdAt", "updatedAt"], + "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" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["stackName", "mainDirectory", "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." + }, + "ServiceDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "stackName", + "displayName", + "description", + "dependencyIds", + "prerequisiteServiceIds", + "runCommand", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "description": "Shareable service definition. Contains the immutable description of a service and its commands. Included in stack exports and shared between installations." + }, + "ServiceConfig": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "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" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "serviceId", + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "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." + }, + "InstallStatus": { + "type": "string", + "enum": ["not-installed", "installing", "installed", "failed"] + }, + "BuildStatus": { + "type": "string", + "enum": ["not-built", "building", "built", "failed"] + }, + "RunStatus": { + "type": "string", + "enum": ["stopped", "starting", "running", "stopping", "error"] + }, + "ServiceStatus": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "installStatus": { + "$ref": "#/definitions/InstallStatus" + }, + "buildStatus": { + "$ref": "#/definitions/BuildStatus" + }, + "runStatus": { + "$ref": "#/definitions/RunStatus" + }, + "lastInstalledAt": { + "type": "string" + }, + "lastBuiltAt": { + "type": "string" + }, + "lastStartedAt": { + "type": "string" + }, + "lastFetchedAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["serviceId", "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." + }, + "ServiceStateEvent": { + "type": "string", + "enum": [ + "run-started", + "run-stopped", + "run-crashed", + "run-restarted", + "install-started", + "install-completed", + "install-failed", + "build-started", + "build-completed", + "build-failed", + "pull-completed" + ], + "description": "Event types for service state transitions. Each value represents a discrete lifecycle event that can occur." + }, + "TriggerSource": { + "type": "string", + "enum": ["api", "mcp", "auto-fetch", "auto-restart", "system"], + "description": "How a state change was triggered. Used to distinguish user actions from automated system behavior." + }, + "ServiceStateHistory": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Auto-increment primary key" + }, + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "event": { + "$ref": "#/definitions/ServiceStateEvent", + "description": "The lifecycle event that occurred" + }, + "previousState": { + "type": "string", + "description": "JSON snapshot of relevant status fields before the change" + }, + "newState": { + "type": "string", + "description": "JSON snapshot of relevant status fields after the change" + }, + "triggeredBy": { + "type": "string", + "description": "Username of the user who triggered the action, or 'system'" + }, + "triggerSource": { + "$ref": "#/definitions/TriggerSource", + "description": "How the action was triggered" + }, + "metadata": { + "type": "string", + "description": "Optional JSON with extra context (exit code, error message, etc.)" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "serviceId", "event", "triggeredBy", "triggerSource", "createdAt"], + "additionalProperties": false, + "description": "Audit log entry for service state transitions. Records every lifecycle event (start, stop, crash, install, build, pull) with full context: who triggered it, how, and any relevant metadata. Entries are never deleted and not included in exports." + }, + "ServiceLogEntry": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "serviceId": { + "type": "string" + }, + "processUid": { + "type": "string" + }, + "stream": { + "type": "string", + "enum": ["stdout", "stderr"] + }, + "line": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "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 + }, + "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" + }, + "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", "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 }" + }, + "installStatus": { + "$ref": "#/definitions/InstallStatus" + }, + "buildStatus": { + "$ref": "#/definitions/BuildStatus" + }, + "runStatus": { + "$ref": "#/definitions/RunStatus" + }, + "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" + }, + "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 }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + } + }, + "required": [ + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "buildStatus", + "createdAt", + "dependencyIds", + "description", + "displayName", + "id", + "installStatus", + "prerequisiteServiceIds", + "runCommand", + "runStatus", + "serviceId", + "stackName", + "updatedAt" + ], + "description": "Full service view combining definition, config, and status for API responses" } } } diff --git a/common/schemas/github-repositories-api.json b/common/schemas/github-repositories-api.json new file mode 100644 index 0000000..7f0e30a --- /dev/null +++ b/common/schemas/github-repositories-api.json @@ -0,0 +1,908 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "GitHubRepoWritableFields": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "required": ["id", "stackName", "url", "displayName", "description"], + "additionalProperties": false + }, + "PostGitHubRepoEndpoint": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/GitHubRepository" + }, + "body": { + "$ref": "#/definitions/WithOptionalId%3CGitHubRepoWritableFields%2C%22id%22%3E" + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "GitHubRepository": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "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." + }, + "WithOptionalId": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "required": ["description", "displayName", "stackName", "url"] + }, + "PatchGitHubRepoEndpoint": { + "$ref": "#/definitions/PatchEndpoint%3CGitHubRepoWritableFields%2C%22id%22%3E" + }, + "PatchEndpoint": { + "type": "object", + "properties": { + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["body", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for updating entities" + }, + "ValidateRepoEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "accessible": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": ["accessible"], + "additionalProperties": false + } + }, + "required": ["url", "result"], + "additionalProperties": false + }, + "GitHubRepositoriesApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/github-repositories": { + "$ref": "#/definitions/GetCollectionEndpoint%3CGitHubRepository%3E" + }, + "/github-repositories/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CGitHubRepository%2C%22id%22%3E" + } + }, + "required": ["/github-repositories", "/github-repositories/:id"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/github-repositories": { + "$ref": "#/definitions/PostGitHubRepoEndpoint" + }, + "/github-repositories/:id/validate": { + "$ref": "#/definitions/ValidateRepoEndpoint" + } + }, + "required": ["/github-repositories", "/github-repositories/:id/validate"], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "properties": { + "/github-repositories/:id": { + "$ref": "#/definitions/PatchGitHubRepoEndpoint" + } + }, + "required": ["/github-repositories/:id"], + "additionalProperties": false + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "properties": { + "/github-repositories/:id": { + "$ref": "#/definitions/DeleteEndpoint%3CGitHubRepository%2C%22id%22%3E" + } + }, + "required": ["/github-repositories/:id"], + "additionalProperties": false + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST", "PATCH", "DELETE"], + "additionalProperties": false + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CGitHubRepository%2C(%22id%22%7C%22stackName%22%7C%22url%22%7C%22displayName%22%7C%22description%22%7C%22createdAt%22%7C%22updatedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CGitHubRepository%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "stackName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "url": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "displayName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "description": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["id", "stackName", "url", "displayName", "description", "createdAt", "updatedAt"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CGitHubRepository%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CGitHubRepository%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CGitHubRepository%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CGitHubRepository%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CGitHubRepository%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "UUID primary key" + }, + "$endsWith": { + "type": "string", + "description": "UUID primary key" + }, + "$like": { + "type": "string", + "description": "UUID primary key" + }, + "$regex": { + "type": "string", + "description": "UUID primary key" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "UUID primary key" + }, + "$ne": { + "type": "string", + "description": "UUID primary key" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "UUID primary key" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "UUID primary key" + } + } + }, + "additionalProperties": false + } + ] + }, + "stackName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$endsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$like": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$regex": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$ne": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + } + }, + "additionalProperties": false + } + ] + }, + "url": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "$endsWith": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "$like": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "$regex": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "$ne": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "displayName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$endsWith": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$like": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$regex": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$ne": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + } + }, + "additionalProperties": false + } + ] + }, + "description": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Optional description" + }, + "$endsWith": { + "type": "string", + "description": "Optional description" + }, + "$like": { + "type": "string", + "description": "Optional description" + }, + "$regex": { + "type": "string", + "description": "Optional description" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Optional description" + }, + "$ne": { + "type": "string", + "description": "Optional description" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Optional description" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Optional description" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/GitHubRepository" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["id", "stackName", "url", "displayName", "description", "createdAt", "updatedAt"] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GitHubRepository" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, + "DeleteEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["url", "result"], + "additionalProperties": false, + "description": "Endpoint model for deleting entities" + } + } +} diff --git a/common/schemas/identity-api.json b/common/schemas/identity-api.json new file mode 100644 index 0000000..4b44e6a --- /dev/null +++ b/common/schemas/identity-api.json @@ -0,0 +1,250 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "IsAuthenticatedAction": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "isAuthenticated": { + "type": "boolean" + } + }, + "required": ["isAuthenticated"], + "additionalProperties": false + } + }, + "required": ["result"], + "additionalProperties": false + }, + "GetCurrentUserAction": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/User" + } + }, + "required": ["result"], + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["username", "roles"], + "additionalProperties": false + }, + "LoginAction": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/User" + }, + "body": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": ["username", "password"], + "additionalProperties": false + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "LogoutAction": { + "type": "object", + "properties": { + "result": {} + }, + "required": ["result"], + "additionalProperties": false + }, + "PasswordResetAction": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": false + }, + "body": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + }, + "required": ["currentPassword", "newPassword"], + "additionalProperties": false + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "IdentityApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/isAuthenticated": { + "$ref": "#/definitions/IsAuthenticatedAction" + }, + "/currentUser": { + "$ref": "#/definitions/GetCurrentUserAction" + } + }, + "required": ["/isAuthenticated", "/currentUser"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/login": { + "$ref": "#/definitions/LoginAction" + }, + "/logout": { + "$ref": "#/definitions/LogoutAction" + }, + "/password-reset": { + "$ref": "#/definitions/PasswordResetAction" + } + }, + "required": ["/login", "/logout", "/password-reset"], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST"], + "additionalProperties": false + } + } +} diff --git a/common/schemas/install-api.json b/common/schemas/install-api.json new file mode 100644 index 0000000..71596b3 --- /dev/null +++ b/common/schemas/install-api.json @@ -0,0 +1,191 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "InstallState": { + "type": "string", + "enum": ["needsInstall", "installed"] + }, + "InstallStateResponse": { + "type": "object", + "properties": { + "state": { + "$ref": "#/definitions/InstallState" + } + }, + "required": ["state"], + "additionalProperties": false + }, + "GetServiceStatusAction": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/InstallStateResponse" + } + }, + "required": ["result"], + "additionalProperties": false + }, + "InstallAction": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": false + }, + "body": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": ["username", "password"], + "additionalProperties": false + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "InstallApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/serviceStatus": { + "$ref": "#/definitions/GetServiceStatusAction" + } + }, + "required": ["/serviceStatus"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/install": { + "$ref": "#/definitions/InstallAction" + } + }, + "required": ["/install"], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST"], + "additionalProperties": false + } + } +} diff --git a/common/schemas/services-api.json b/common/schemas/services-api.json new file mode 100644 index 0000000..2b1be20 --- /dev/null +++ b/common/schemas/services-api.json @@ -0,0 +1,2443 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ServiceDefinitionWritableFields": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + } + }, + "required": [ + "id", + "stackName", + "displayName", + "description", + "dependencyIds", + "prerequisiteServiceIds", + "runCommand" + ], + "additionalProperties": false + }, + "ServiceConfigWritableFields": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "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" + } + }, + "required": ["serviceId", "autoFetchEnabled", "autoFetchIntervalMinutes", "autoRestartOnFetch"], + "additionalProperties": false + }, + "ServiceWritableFields": { + "type": "object", + "additionalProperties": false, + "properties": { + "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" + }, + "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 }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + } + }, + "required": [ + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "dependencyIds", + "description", + "displayName", + "id", + "prerequisiteServiceIds", + "runCommand", + "stackName" + ] + }, + "PostServiceEndpoint": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/ServiceView" + }, + "body": { + "$ref": "#/definitions/WithOptionalId%3CServiceWritableFields%2C%22id%22%3E" + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "ServiceView": { + "type": "object", + "additionalProperties": false, + "properties": { + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "installStatus": { + "$ref": "#/definitions/InstallStatus" + }, + "buildStatus": { + "$ref": "#/definitions/BuildStatus" + }, + "runStatus": { + "$ref": "#/definitions/RunStatus" + }, + "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" + }, + "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 }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + } + }, + "required": [ + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "buildStatus", + "createdAt", + "dependencyIds", + "description", + "displayName", + "id", + "installStatus", + "prerequisiteServiceIds", + "runCommand", + "runStatus", + "serviceId", + "stackName", + "updatedAt" + ], + "description": "Full service view combining definition, config, and status for API responses" + }, + "InstallStatus": { + "type": "string", + "enum": ["not-installed", "installing", "installed", "failed"] + }, + "BuildStatus": { + "type": "string", + "enum": ["not-built", "building", "built", "failed"] + }, + "RunStatus": { + "type": "string", + "enum": ["stopped", "starting", "running", "stopping", "error"] + }, + "WithOptionalId": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + }, + "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" + } + }, + "required": [ + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "dependencyIds", + "description", + "displayName", + "prerequisiteServiceIds", + "runCommand", + "stackName" + ] + }, + "PatchServiceEndpoint": { + "$ref": "#/definitions/PatchEndpoint%3CServiceWritableFields%2C%22id%22%3E" + }, + "PatchEndpoint": { + "type": "object", + "properties": { + "body": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + }, + "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" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["body", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for updating entities" + }, + "ServiceActionEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "serviceId": { + "type": "string" + } + }, + "required": ["success", "serviceId"], + "additionalProperties": false + } + }, + "required": ["url", "result"], + "additionalProperties": false + }, + "ServiceLogsEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "properties": { + "lines": { + "type": "number" + }, + "processUid": { + "type": "string" + }, + "search": { + "type": "string" + } + }, + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/ServiceLogEntry" + } + } + }, + "required": ["entries"], + "additionalProperties": false + } + }, + "required": ["url", "query", "result"], + "additionalProperties": false + }, + "ServiceLogEntry": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "serviceId": { + "type": "string" + }, + "processUid": { + "type": "string" + }, + "stream": { + "type": "string", + "enum": ["stdout", "stderr"] + }, + "line": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "serviceId", "processUid", "stream", "line", "createdAt"], + "additionalProperties": false + }, + "ClearServiceLogsEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": false + } + }, + "required": ["url", "result"], + "additionalProperties": false + }, + "ServiceHistoryEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "query": { + "type": "object", + "properties": { + "limit": { + "type": "number" + } + }, + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/ServiceStateHistory" + } + } + }, + "required": ["entries"], + "additionalProperties": false + } + }, + "required": ["url", "query", "result"], + "additionalProperties": false + }, + "ServiceStateHistory": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "Auto-increment primary key" + }, + "serviceId": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "event": { + "$ref": "#/definitions/ServiceStateEvent", + "description": "The lifecycle event that occurred" + }, + "previousState": { + "type": "string", + "description": "JSON snapshot of relevant status fields before the change" + }, + "newState": { + "type": "string", + "description": "JSON snapshot of relevant status fields after the change" + }, + "triggeredBy": { + "type": "string", + "description": "Username of the user who triggered the action, or 'system'" + }, + "triggerSource": { + "$ref": "#/definitions/TriggerSource", + "description": "How the action was triggered" + }, + "metadata": { + "type": "string", + "description": "Optional JSON with extra context (exit code, error message, etc.)" + }, + "createdAt": { + "type": "string" + } + }, + "required": ["id", "serviceId", "event", "triggeredBy", "triggerSource", "createdAt"], + "additionalProperties": false, + "description": "Audit log entry for service state transitions. Records every lifecycle event (start, stop, crash, install, build, pull) with full context: who triggered it, how, and any relevant metadata. Entries are never deleted and not included in exports." + }, + "ServiceStateEvent": { + "type": "string", + "enum": [ + "run-started", + "run-stopped", + "run-crashed", + "run-restarted", + "install-started", + "install-completed", + "install-failed", + "build-started", + "build-completed", + "build-failed", + "pull-completed" + ], + "description": "Event types for service state transitions. Each value represents a discrete lifecycle event that can occur." + }, + "TriggerSource": { + "type": "string", + "enum": ["api", "mcp", "auto-fetch", "auto-restart", "system"], + "description": "How a state change was triggered. Used to distinguish user actions from automated system behavior." + }, + "ServicesApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/services": { + "$ref": "#/definitions/GetCollectionEndpoint%3CServiceView%3E" + }, + "/services/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CServiceView%2C%22id%22%3E" + }, + "/services/:id/logs": { + "$ref": "#/definitions/ServiceLogsEndpoint" + }, + "/services/:id/history": { + "$ref": "#/definitions/ServiceHistoryEndpoint" + } + }, + "required": ["/services", "/services/:id", "/services/:id/logs", "/services/:id/history"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/services": { + "$ref": "#/definitions/PostServiceEndpoint" + }, + "/services/:id/start": { + "$ref": "#/definitions/ServiceActionEndpoint" + }, + "/services/:id/stop": { + "$ref": "#/definitions/ServiceActionEndpoint" + }, + "/services/:id/restart": { + "$ref": "#/definitions/ServiceActionEndpoint" + }, + "/services/:id/install": { + "$ref": "#/definitions/ServiceActionEndpoint" + }, + "/services/:id/build": { + "$ref": "#/definitions/ServiceActionEndpoint" + }, + "/services/:id/pull": { + "$ref": "#/definitions/ServiceActionEndpoint" + } + }, + "required": [ + "/services", + "/services/:id/start", + "/services/:id/stop", + "/services/:id/restart", + "/services/:id/install", + "/services/:id/build", + "/services/:id/pull" + ], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "properties": { + "/services/:id": { + "$ref": "#/definitions/PatchServiceEndpoint" + } + }, + "required": ["/services/:id"], + "additionalProperties": false + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "properties": { + "/services/:id": { + "$ref": "#/definitions/DeleteEndpoint%3CServiceDefinition%2C%22id%22%3E" + }, + "/services/:id/logs": { + "$ref": "#/definitions/ClearServiceLogsEndpoint" + } + }, + "required": ["/services/:id", "/services/:id/logs"], + "additionalProperties": false + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST", "PATCH", "DELETE"], + "additionalProperties": false + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "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%22dependencyIds%22%7C%22prerequisiteServiceIds%22%7C%22installCommand%22%7C%22buildCommand%22%7C%22runCommand%22%7C%22createdAt%22%7C%22updatedAt%22%7C%22serviceId%22%7C%22autoFetchEnabled%22%7C%22autoFetchIntervalMinutes%22%7C%22autoRestartOnFetch%22%7C%22installStatus%22%7C%22buildStatus%22%7C%22runStatus%22%7C%22lastInstalledAt%22%7C%22lastBuiltAt%22%7C%22lastStartedAt%22%7C%22lastFetchedAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CServiceView%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "stackName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "displayName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "description": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "workingDirectory": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "repositoryId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "dependencyIds": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "prerequisiteServiceIds": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "installCommand": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "buildCommand": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "runCommand": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "serviceId": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "autoFetchEnabled": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "autoFetchIntervalMinutes": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "autoRestartOnFetch": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "installStatus": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "buildStatus": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "runStatus": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "lastInstalledAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "lastBuiltAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "lastStartedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "lastFetchedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "stackName", + "displayName", + "description", + "workingDirectory", + "repositoryId", + "dependencyIds", + "prerequisiteServiceIds", + "installCommand", + "buildCommand", + "runCommand", + "createdAt", + "updatedAt", + "serviceId", + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "installStatus", + "buildStatus", + "runStatus", + "lastInstalledAt", + "lastBuiltAt", + "lastStartedAt", + "lastFetchedAt" + ] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CServiceView%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CServiceView%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CServiceView%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CServiceView%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CServiceView%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "UUID primary key" + }, + "$endsWith": { + "type": "string", + "description": "UUID primary key" + }, + "$like": { + "type": "string", + "description": "UUID primary key" + }, + "$regex": { + "type": "string", + "description": "UUID primary key" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "UUID primary key" + }, + "$ne": { + "type": "string", + "description": "UUID primary key" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "UUID primary key" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "UUID primary key" + } + } + }, + "additionalProperties": false + } + ] + }, + "stackName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$endsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$like": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$regex": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$ne": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + } + }, + "additionalProperties": false + } + ] + }, + "displayName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$endsWith": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$like": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$regex": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$ne": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + } + }, + "additionalProperties": false + } + ] + }, + "description": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Optional description of what this service does" + }, + "$endsWith": { + "type": "string", + "description": "Optional description of what this service does" + }, + "$like": { + "type": "string", + "description": "Optional description of what this service does" + }, + "$regex": { + "type": "string", + "description": "Optional description of what this service does" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Optional description of what this service does" + }, + "$ne": { + "type": "string", + "description": "Optional description of what this service does" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Optional description of what this service does" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Optional description of what this service does" + } + } + }, + "additionalProperties": false + } + ] + }, + "workingDirectory": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "$ne": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "repositoryId": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "$ne": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Optional FK to {@link GitHubRepository.id }" + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Optional FK to {@link GitHubRepository.id }" + } + } + }, + "additionalProperties": false + } + ] + }, + "dependencyIds": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } entities required by this service" + }, + "$ne": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } entities required by this service" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } entities required by this service" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } entities required by this service" + } + } + }, + "additionalProperties": false + } + ] + }, + "prerequisiteServiceIds": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of other {@link ServiceDefinition } entities that must be running first" + }, + "$ne": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of other {@link ServiceDefinition } entities that must be running first" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of other {@link ServiceDefinition } entities that must be running first" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of other {@link ServiceDefinition } entities that must be running first" + } + } + }, + "additionalProperties": false + } + ] + }, + "installCommand": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Shell command to install dependencies (e.g. \"npm install\")" + }, + "$ne": { + "type": "string", + "description": "Shell command to install dependencies (e.g. \"npm install\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Shell command to install dependencies (e.g. \"npm install\")" + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Shell command to install dependencies (e.g. \"npm install\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "buildCommand": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Shell command to build the service (e.g. \"npm run build\")" + }, + "$ne": { + "type": "string", + "description": "Shell command to build the service (e.g. \"npm run build\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Shell command to build the service (e.g. \"npm run build\")" + } + }, + "$nin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "not": {} + } + ], + "description": "Shell command to build the service (e.g. \"npm run build\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "runCommand": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + }, + "$endsWith": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + }, + "$like": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + }, + "$regex": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + }, + "$ne": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Shell command to run the service (e.g. \"npm start\")" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "serviceId": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "$endsWith": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "$like": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "$regex": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + }, + "$ne": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link ServiceDefinition.id }" + } + } + }, + "additionalProperties": false + } + ] + }, + "autoFetchEnabled": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "boolean", + "description": "Whether automatic git fetch is enabled" + }, + "$ne": { + "type": "boolean", + "description": "Whether automatic git fetch is enabled" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "boolean", + "description": "Whether automatic git fetch is enabled" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "boolean", + "description": "Whether automatic git fetch is enabled" + } + } + }, + "additionalProperties": false + } + ] + }, + "autoFetchIntervalMinutes": { + "anyOf": [ + { + "type": "object", + "properties": { + "$gt": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + }, + "$gte": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + }, + "$lt": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + }, + "$lte": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + }, + "$ne": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "number", + "description": "Interval in minutes between automatic git fetches" + } + } + }, + "additionalProperties": false + } + ] + }, + "autoRestartOnFetch": { + "anyOf": [ + { + "type": "object", + "properties": { + "$eq": { + "type": "boolean", + "description": "Whether to automatically restart the service when new commits are fetched" + }, + "$ne": { + "type": "boolean", + "description": "Whether to automatically restart the service when new commits are fetched" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "boolean", + "description": "Whether to automatically restart the service when new commits are fetched" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "boolean", + "description": "Whether to automatically restart the service when new commits are fetched" + } + } + }, + "additionalProperties": false + } + ] + }, + "installStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "$ref": "#/definitions/InstallStatus" + }, + "$endsWith": { + "$ref": "#/definitions/InstallStatus" + }, + "$like": { + "$ref": "#/definitions/InstallStatus" + }, + "$regex": { + "$ref": "#/definitions/InstallStatus" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "$ref": "#/definitions/InstallStatus" + }, + "$ne": { + "$ref": "#/definitions/InstallStatus" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "$ref": "#/definitions/InstallStatus" + } + }, + "$nin": { + "type": "array", + "items": { + "$ref": "#/definitions/InstallStatus" + } + } + }, + "additionalProperties": false + } + ] + }, + "buildStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "$ref": "#/definitions/BuildStatus" + }, + "$endsWith": { + "$ref": "#/definitions/BuildStatus" + }, + "$like": { + "$ref": "#/definitions/BuildStatus" + }, + "$regex": { + "$ref": "#/definitions/BuildStatus" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "$ref": "#/definitions/BuildStatus" + }, + "$ne": { + "$ref": "#/definitions/BuildStatus" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "$ref": "#/definitions/BuildStatus" + } + }, + "$nin": { + "type": "array", + "items": { + "$ref": "#/definitions/BuildStatus" + } + } + }, + "additionalProperties": false + } + ] + }, + "runStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "$ref": "#/definitions/RunStatus" + }, + "$endsWith": { + "$ref": "#/definitions/RunStatus" + }, + "$like": { + "$ref": "#/definitions/RunStatus" + }, + "$regex": { + "$ref": "#/definitions/RunStatus" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "$ref": "#/definitions/RunStatus" + }, + "$ne": { + "$ref": "#/definitions/RunStatus" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "$ref": "#/definitions/RunStatus" + } + }, + "$nin": { + "type": "array", + "items": { + "$ref": "#/definitions/RunStatus" + } + } + }, + "additionalProperties": false + } + ] + }, + "lastInstalledAt": { + "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 + } + ] + }, + "lastBuiltAt": { + "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 + } + ] + }, + "lastStartedAt": { + "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 + } + ] + }, + "lastFetchedAt": { + "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 + } + ] + } + } + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/ServiceView" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "id", + "stackName", + "displayName", + "description", + "workingDirectory", + "repositoryId", + "dependencyIds", + "prerequisiteServiceIds", + "installCommand", + "buildCommand", + "runCommand", + "createdAt", + "updatedAt", + "serviceId", + "autoFetchEnabled", + "autoFetchIntervalMinutes", + "autoRestartOnFetch", + "installStatus", + "buildStatus", + "runStatus", + "lastInstalledAt", + "lastBuiltAt", + "lastStartedAt", + "lastFetchedAt" + ] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/ServiceView" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, + "DeleteEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["url", "result"], + "additionalProperties": false, + "description": "Endpoint model for deleting entities" + } + } +} diff --git a/common/schemas/stacks-api.json b/common/schemas/stacks-api.json new file mode 100644 index 0000000..898bfa7 --- /dev/null +++ b/common/schemas/stacks-api.json @@ -0,0 +1,1242 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "StackWritableFields": { + "type": "object", + "additionalProperties": false, + "properties": { + "mainDirectory": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + }, + "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": ["description", "displayName", "mainDirectory", "name"] + }, + "PostStackEndpoint": { + "type": "object", + "properties": { + "result": { + "$ref": "#/definitions/StackView" + }, + "body": { + "$ref": "#/definitions/WithOptionalId%3CStackWritableFields%2C%22name%22%3E" + } + }, + "required": ["result", "body"], + "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" + }, + "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", "mainDirectory", "name", "stackName", "updatedAt"], + "description": "Full stack view combining definition and config for API responses" + }, + "WithOptionalId": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "mainDirectory": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + } + }, + "required": ["description", "displayName", "mainDirectory"] + }, + "PatchStackEndpoint": { + "$ref": "#/definitions/PatchEndpoint%3CStackWritableFields%2C%22name%22%3E" + }, + "PatchEndpoint": { + "type": "object", + "properties": { + "body": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "mainDirectory": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["body", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for updating entities" + }, + "ExportStackEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object", + "properties": { + "stack": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this stack does" + } + }, + "required": ["name", "displayName", "description"], + "additionalProperties": false + }, + "services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + } + }, + "required": [ + "id", + "stackName", + "displayName", + "description", + "dependencyIds", + "prerequisiteServiceIds", + "runCommand" + ], + "additionalProperties": false + } + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "required": ["id", "stackName", "url", "displayName", "description"], + "additionalProperties": false + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "required": ["id", "stackName", "name", "checkCommand", "installationHelp"], + "additionalProperties": false + } + } + }, + "required": ["stack", "services", "repositories", "dependencies"], + "additionalProperties": false + } + }, + "required": ["url", "result"], + "additionalProperties": false + }, + "ImportStackEndpoint": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "additionalProperties": false + }, + "body": { + "type": "object", + "properties": { + "stack": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this stack does" + } + }, + "required": ["name", "displayName", "description"], + "additionalProperties": false + }, + "services": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description of what this service does" + }, + "workingDirectory": { + "type": "string", + "description": "Optional relative path within stack for grouping (e.g. \"frontends/public\")" + }, + "repositoryId": { + "type": "string", + "description": "Optional FK to {@link GitHubRepository.id }" + }, + "dependencyIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "IDs of {@link Dependency } 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\")" + } + }, + "required": [ + "id", + "stackName", + "displayName", + "description", + "dependencyIds", + "prerequisiteServiceIds", + "runCommand" + ], + "additionalProperties": false + } + }, + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "url": { + "type": "string", + "description": "Full URL to the git repository (e.g. \"https://github.com/user/repo\")" + }, + "displayName": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "required": ["id", "stackName", "url", "displayName", "description"], + "additionalProperties": false + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "UUID primary key" + }, + "stackName": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "name": { + "type": "string", + "description": "Human-readable dependency name (e.g. \"Node.js\")" + }, + "checkCommand": { + "type": "string", + "description": "Shell command to check if the dependency is satisfied (e.g. \"node --version\")" + }, + "installationHelp": { + "type": "string", + "description": "Help text shown when the dependency check fails" + } + }, + "required": ["id", "stackName", "name", "checkCommand", "installationHelp"], + "additionalProperties": false + } + }, + "config": { + "type": "object", + "properties": { + "mainDirectory": { + "type": "string" + }, + "services": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "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" + } + }, + "additionalProperties": false + } + } + }, + "required": ["mainDirectory"], + "additionalProperties": false + } + }, + "required": ["stack", "services", "repositories", "dependencies", "config"], + "additionalProperties": false + } + }, + "required": ["result", "body"], + "additionalProperties": false + }, + "StacksApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/stacks": { + "$ref": "#/definitions/GetCollectionEndpoint%3CStackView%3E" + }, + "/stacks/:id": { + "$ref": "#/definitions/GetEntityEndpoint%3CStackView%2C%22name%22%3E" + }, + "/stacks/:id/export": { + "$ref": "#/definitions/ExportStackEndpoint" + } + }, + "required": ["/stacks", "/stacks/:id", "/stacks/:id/export"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/stacks": { + "$ref": "#/definitions/PostStackEndpoint" + }, + "/stacks/import": { + "$ref": "#/definitions/ImportStackEndpoint" + } + }, + "required": ["/stacks", "/stacks/import"], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "properties": { + "/stacks/:id": { + "$ref": "#/definitions/PatchStackEndpoint" + } + }, + "required": ["/stacks/:id"], + "additionalProperties": false + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "properties": { + "/stacks/:id": { + "$ref": "#/definitions/DeleteEndpoint%3CStackDefinition%2C%22name%22%3E" + } + }, + "required": ["/stacks/:id"], + "additionalProperties": false + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST", "PATCH", "DELETE"], + "additionalProperties": false + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CStackView%2C(%22name%22%7C%22displayName%22%7C%22description%22%7C%22createdAt%22%7C%22updatedAt%22%7C%22stackName%22%7C%22mainDirectory%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CStackView%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "displayName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "description": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "updatedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "stackName": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "mainDirectory": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["name", "displayName", "description", "createdAt", "updatedAt", "stackName", "mainDirectory"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CStackView%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CStackView%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CStackView%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CStackView%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CStackView%3E" + } + }, + "name": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "$endsWith": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "$like": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "$regex": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + }, + "$ne": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + } + } + }, + "additionalProperties": false + } + ] + }, + "displayName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$endsWith": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$like": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$regex": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Human-readable name shown in the UI" + }, + "$ne": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Human-readable name shown in the UI" + } + } + }, + "additionalProperties": false + } + ] + }, + "description": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "$endsWith": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "$like": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "$regex": { + "type": "string", + "description": "Optional description of what this stack does" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Optional description of what this stack does" + }, + "$ne": { + "type": "string", + "description": "Optional description of what this stack does" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Optional description of what this stack does" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Optional description of what this stack does" + } + } + }, + "additionalProperties": false + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "stackName": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$endsWith": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$like": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$regex": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + }, + "$ne": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "FK to {@link StackDefinition.name }" + } + } + }, + "additionalProperties": false + } + ] + }, + "mainDirectory": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + }, + "$endsWith": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + }, + "$like": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + }, + "$regex": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + }, + "$ne": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string", + "description": "Absolute path to the root directory for all services in this stack" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/StackView" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "GetEntityEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["name", "displayName", "description", "createdAt", "updatedAt", "stackName", "mainDirectory"] + }, + "description": "The list of fields to select" + } + }, + "additionalProperties": false + }, + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The entity's unique identifier" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/StackView" + } + }, + "required": ["query", "url", "result"], + "additionalProperties": false, + "description": "Endpoint model for getting a single entity" + }, + "DeleteEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique kebab-case identifier for the stack" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["url", "result"], + "additionalProperties": false, + "description": "Endpoint model for deleting entities" + } + } +} diff --git a/common/schemas/tokens-api.json b/common/schemas/tokens-api.json new file mode 100644 index 0000000..b781e5f --- /dev/null +++ b/common/schemas/tokens-api.json @@ -0,0 +1,584 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "CreateTokenEndpoint": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "token": { + "$ref": "#/definitions/PublicApiToken" + }, + "plainTextToken": { + "type": "string" + } + }, + "required": ["token", "plainTextToken"], + "additionalProperties": false + }, + "body": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["result", "body"], + "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 + }, + "TokensApi": { + "type": "object", + "properties": { + "GET": { + "type": "object", + "properties": { + "/tokens": { + "$ref": "#/definitions/GetCollectionEndpoint%3CPublicApiToken%3E" + } + }, + "required": ["/tokens"], + "additionalProperties": false + }, + "POST": { + "type": "object", + "properties": { + "/tokens": { + "$ref": "#/definitions/CreateTokenEndpoint" + } + }, + "required": ["/tokens"], + "additionalProperties": false + }, + "PATCH": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "PUT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "DELETE": { + "type": "object", + "properties": { + "/tokens/:id": { + "$ref": "#/definitions/DeleteEndpoint%3CApiToken%2C%22id%22%3E" + } + }, + "required": ["/tokens/:id"], + "additionalProperties": false + }, + "HEAD": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "CONNECT": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "TRACE": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + }, + "OPTIONS": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "result": {}, + "url": {}, + "query": {}, + "body": {}, + "headers": {} + }, + "required": ["result"], + "additionalProperties": false + } + } + }, + "required": ["GET", "POST", "DELETE"], + "additionalProperties": false + }, + "GetCollectionEndpoint": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "findOptions": { + "$ref": "#/definitions/FindOptions%3CPublicApiToken%2C(%22id%22%7C%22username%22%7C%22name%22%7C%22lastUsedAt%22%7C%22createdAt%22)%5B%5D%3E" + } + }, + "additionalProperties": false + }, + "result": { + "$ref": "#/definitions/GetCollectionResult%3CPublicApiToken%3E" + } + }, + "required": ["query", "result"], + "additionalProperties": false, + "description": "Rest endpoint model for getting / querying collections" + }, + "FindOptions": { + "type": "object", + "properties": { + "top": { + "type": "number", + "description": "Limits the hits" + }, + "skip": { + "type": "number", + "description": "Skips the first N hit" + }, + "order": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "username": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "name": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "lastUsedAt": { + "type": "string", + "enum": ["ASC", "DESC"] + }, + "createdAt": { + "type": "string", + "enum": ["ASC", "DESC"] + } + }, + "additionalProperties": false, + "description": "Sets up an order by a field and a direction" + }, + "select": { + "type": "array", + "items": { + "type": "string", + "enum": ["id", "username", "name", "lastUsedAt", "createdAt"] + }, + "description": "The result set will be limited to these fields" + }, + "filter": { + "$ref": "#/definitions/FilterType%3CPublicApiToken%3E", + "description": "The fields should match this filter" + } + }, + "additionalProperties": false, + "description": "Type for default filtering model" + }, + "FilterType": { + "type": "object", + "additionalProperties": false, + "properties": { + "$and": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CPublicApiToken%3E" + } + }, + "$not": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CPublicApiToken%3E" + } + }, + "$nor": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CPublicApiToken%3E" + } + }, + "$or": { + "type": "array", + "items": { + "$ref": "#/definitions/FilterType%3CPublicApiToken%3E" + } + }, + "id": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "username": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "name": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "lastUsedAt": { + "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 + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "object", + "properties": { + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$like": { + "type": "string" + }, + "$regex": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$eq": { + "type": "string" + }, + "$ne": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "$in": { + "type": "array", + "items": { + "type": "string" + } + }, + "$nin": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "GetCollectionResult": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "The Total count of entities" + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/definitions/PublicApiToken" + }, + "description": "List of the selected entities" + } + }, + "required": ["count", "entries"], + "additionalProperties": false, + "description": "Response Model for GetCollection" + }, + "DeleteEndpoint": { + "type": "object", + "properties": { + "url": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + }, + "result": { + "type": "object" + } + }, + "required": ["url", "result"], + "additionalProperties": false, + "description": "Endpoint model for deleting entities" + } + } +} diff --git a/common/src/apis/dependencies.ts b/common/src/apis/dependencies.ts new file mode 100644 index 0000000..f43814b --- /dev/null +++ b/common/src/apis/dependencies.ts @@ -0,0 +1,34 @@ +import type { WithOptionalId } from '@furystack/core' +import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' +import type { Dependency } from '../models/dependency.js' + +export type DependencyWritableFields = Omit + +export type PostDependencyEndpoint = { + result: Dependency + body: WithOptionalId +} + +export type PatchDependencyEndpoint = PatchEndpoint + +export type CheckDependencyEndpoint = { + url: { id: string } + result: { satisfied: boolean; output: string } +} + +export interface DependenciesApi extends RestApi { + GET: { + '/dependencies': GetCollectionEndpoint + '/dependencies/:id': GetEntityEndpoint + } + POST: { + '/dependencies': PostDependencyEndpoint + '/dependencies/:id/check': CheckDependencyEndpoint + } + PATCH: { + '/dependencies/:id': PatchDependencyEndpoint + } + DELETE: { + '/dependencies/:id': DeleteEndpoint + } +} diff --git a/common/src/apis/github-repositories.ts b/common/src/apis/github-repositories.ts new file mode 100644 index 0000000..975dba5 --- /dev/null +++ b/common/src/apis/github-repositories.ts @@ -0,0 +1,34 @@ +import type { WithOptionalId } from '@furystack/core' +import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' +import type { GitHubRepository } from '../models/github-repository.js' + +export type GitHubRepoWritableFields = Omit + +export type PostGitHubRepoEndpoint = { + result: GitHubRepository + body: WithOptionalId +} + +export type PatchGitHubRepoEndpoint = PatchEndpoint + +export type ValidateRepoEndpoint = { + url: { id: string } + result: { accessible: boolean; message?: string } +} + +export interface GitHubRepositoriesApi extends RestApi { + GET: { + '/github-repositories': GetCollectionEndpoint + '/github-repositories/:id': GetEntityEndpoint + } + POST: { + '/github-repositories': PostGitHubRepoEndpoint + '/github-repositories/:id/validate': ValidateRepoEndpoint + } + PATCH: { + '/github-repositories/:id': PatchGitHubRepoEndpoint + } + DELETE: { + '/github-repositories/:id': DeleteEndpoint + } +} diff --git a/common/src/apis/identity.ts b/common/src/apis/identity.ts new file mode 100644 index 0000000..8e1039a --- /dev/null +++ b/common/src/apis/identity.ts @@ -0,0 +1,27 @@ +import type { RestApi } from '@furystack/rest' +import type { User } from '../models/user.js' + +export type IsAuthenticatedAction = { result: { isAuthenticated: boolean } } +export type GetCurrentUserAction = { result: User } +export type LoginAction = { result: User; body: { username: string; password: string } } +export type LogoutAction = { result: unknown } + +export type PasswordResetAction = { + result: { success: boolean } + body: { + currentPassword: string + newPassword: string + } +} + +export interface IdentityApi extends RestApi { + GET: { + '/isAuthenticated': IsAuthenticatedAction + '/currentUser': GetCurrentUserAction + } + POST: { + '/login': LoginAction + '/logout': LogoutAction + '/password-reset': PasswordResetAction + } +} diff --git a/common/src/apis/index.ts b/common/src/apis/index.ts new file mode 100644 index 0000000..b94ca86 --- /dev/null +++ b/common/src/apis/index.ts @@ -0,0 +1,7 @@ +export * from './install.js' +export * from './identity.js' +export * from './stacks.js' +export * from './services.js' +export * from './github-repositories.js' +export * from './dependencies.js' +export * from './tokens.js' diff --git a/common/src/apis/install.ts b/common/src/apis/install.ts new file mode 100644 index 0000000..7823211 --- /dev/null +++ b/common/src/apis/install.ts @@ -0,0 +1,20 @@ +import type { RestApi } from '@furystack/rest' + +export type InstallState = 'needsInstall' | 'installed' + +export type InstallStateResponse = { + state: InstallState +} + +export type GetServiceStatusAction = { result: InstallStateResponse } + +export type InstallAction = { result: { success: boolean }; body: { username: string; password: string } } + +export interface InstallApi extends RestApi { + GET: { + '/serviceStatus': GetServiceStatusAction + } + POST: { + '/install': InstallAction + } +} diff --git a/common/src/apis/services.ts b/common/src/apis/services.ts new file mode 100644 index 0000000..b51f956 --- /dev/null +++ b/common/src/apis/services.ts @@ -0,0 +1,62 @@ +import type { WithOptionalId } from '@furystack/core' +import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' +import type { ServiceConfig } from '../models/service-config.js' +import type { ServiceDefinition } from '../models/service-definition.js' +import type { ServiceLogEntry } from '../models/service-log-entry.js' +import type { ServiceStateHistory } from '../models/service-state-history.js' +import type { ServiceView } from '../models/views.js' + +export type ServiceDefinitionWritableFields = Omit +export type ServiceConfigWritableFields = Omit +export type ServiceWritableFields = ServiceDefinitionWritableFields & Omit + +export type PostServiceEndpoint = { + result: ServiceView + body: WithOptionalId +} + +export type PatchServiceEndpoint = PatchEndpoint + +export type ServiceActionEndpoint = { url: { id: string }; result: { success: boolean; serviceId: string } } + +export type ServiceLogsEndpoint = { + url: { id: string } + query: { lines?: number; processUid?: string; search?: string } + result: { entries: ServiceLogEntry[] } +} + +export type ClearServiceLogsEndpoint = { + url: { id: string } + result: { success: boolean } +} + +export type ServiceHistoryEndpoint = { + url: { id: string } + query: { limit?: number } + result: { entries: ServiceStateHistory[] } +} + +export interface ServicesApi extends RestApi { + GET: { + '/services': GetCollectionEndpoint + '/services/:id': GetEntityEndpoint + '/services/:id/logs': ServiceLogsEndpoint + '/services/:id/history': ServiceHistoryEndpoint + } + POST: { + '/services': PostServiceEndpoint + '/services/:id/start': ServiceActionEndpoint + '/services/:id/stop': ServiceActionEndpoint + '/services/:id/restart': ServiceActionEndpoint + '/services/:id/install': ServiceActionEndpoint + '/services/:id/build': ServiceActionEndpoint + '/services/:id/pull': ServiceActionEndpoint + } + PATCH: { + '/services/:id': PatchServiceEndpoint + } + DELETE: { + '/services/:id': DeleteEndpoint + '/services/:id/logs': ClearServiceLogsEndpoint + } +} diff --git a/common/src/apis/stacks.ts b/common/src/apis/stacks.ts new file mode 100644 index 0000000..f675385 --- /dev/null +++ b/common/src/apis/stacks.ts @@ -0,0 +1,64 @@ +import type { WithOptionalId } from '@furystack/core' +import type { DeleteEndpoint, GetCollectionEndpoint, GetEntityEndpoint, PatchEndpoint, RestApi } from '@furystack/rest' +import type { Dependency } from '../models/dependency.js' +import type { GitHubRepository } from '../models/github-repository.js' +import type { ServiceConfig } from '../models/service-config.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' +import type { StackView } from '../models/views.js' + +export type StackWritableFields = Omit & + Omit +export type PostStackEndpoint = { result: StackView; body: WithOptionalId } +export type PatchStackEndpoint = PatchEndpoint + +type ShareableStackDefinition = Omit +type ShareableServiceDefinition = Omit +type ShareableGitHubRepository = Omit +type ShareableDependency = Omit + +export type ExportStackEndpoint = { + url: { id: string } + result: { + stack: ShareableStackDefinition + services: ShareableServiceDefinition[] + repositories: ShareableGitHubRepository[] + dependencies: ShareableDependency[] + } +} + +export type ImportStackEndpoint = { + result: { success: boolean } + body: { + stack: ShareableStackDefinition + services: ShareableServiceDefinition[] + repositories: ShareableGitHubRepository[] + dependencies: ShareableDependency[] + config: { + mainDirectory: string + services?: Record< + string, + Partial> + > + } + } +} + +export interface StacksApi extends RestApi { + GET: { + '/stacks': GetCollectionEndpoint + '/stacks/:id': GetEntityEndpoint + '/stacks/:id/export': ExportStackEndpoint + } + POST: { + '/stacks': PostStackEndpoint + '/stacks/import': ImportStackEndpoint + } + PATCH: { + '/stacks/:id': PatchStackEndpoint + } + DELETE: { + '/stacks/:id': DeleteEndpoint + } +} diff --git a/common/src/apis/tokens.ts b/common/src/apis/tokens.ts new file mode 100644 index 0000000..fba8f2e --- /dev/null +++ b/common/src/apis/tokens.ts @@ -0,0 +1,20 @@ +import type { DeleteEndpoint, GetCollectionEndpoint, RestApi } from '@furystack/rest' +import type { ApiToken } from '../models/api-token.js' +import type { PublicApiToken } from '../models/public-api-token.js' + +export type CreateTokenEndpoint = { + result: { token: PublicApiToken; plainTextToken: string } + body: { name: string } +} + +export interface TokensApi extends RestApi { + GET: { + '/tokens': GetCollectionEndpoint + } + POST: { + '/tokens': CreateTokenEndpoint + } + DELETE: { + '/tokens/:id': DeleteEndpoint + } +} diff --git a/common/src/bin/create-schemas.ts b/common/src/bin/create-schemas.ts index 940c7aa..1d58f62 100644 --- a/common/src/bin/create-schemas.ts +++ b/common/src/bin/create-schemas.ts @@ -8,9 +8,6 @@ export interface SchemaGenerationSetting { type: string } -/** - * Entity schemas, e.g. User, Session, etc... - */ export const entityValues: SchemaGenerationSetting[] = [ { inputFile: './src/models/*.ts', @@ -21,8 +18,38 @@ export const entityValues: SchemaGenerationSetting[] = [ export const apiValues: SchemaGenerationSetting[] = [ { - inputFile: './src/stack-craft-api.ts', - outputFile: './schemas/stack-craft-api.json', + inputFile: './src/apis/identity.ts', + outputFile: './schemas/identity-api.json', + type: '*', + }, + { + inputFile: './src/apis/install.ts', + outputFile: './schemas/install-api.json', + type: '*', + }, + { + inputFile: './src/apis/stacks.ts', + outputFile: './schemas/stacks-api.json', + type: '*', + }, + { + inputFile: './src/apis/services.ts', + outputFile: './schemas/services-api.json', + type: '*', + }, + { + inputFile: './src/apis/github-repositories.ts', + outputFile: './schemas/github-repositories-api.json', + type: '*', + }, + { + inputFile: './src/apis/dependencies.ts', + outputFile: './schemas/dependencies-api.json', + type: '*', + }, + { + inputFile: './src/apis/tokens.ts', + outputFile: './schemas/tokens-api.json', type: '*', }, ] @@ -35,7 +62,6 @@ export const exec = async (): Promise => { path: join(process.cwd(), schemaValue.inputFile), tsconfig: join(process.cwd(), './tsconfig.json'), skipTypeCheck: true, - // expose: 'all', }).createSchema(schemaValue.type) await promises.writeFile(join(process.cwd(), schemaValue.outputFile), JSON.stringify(schema, null, 2)) } catch (error) { diff --git a/common/src/boilerplate-api.ts b/common/src/boilerplate-api.ts deleted file mode 100644 index 39380ad..0000000 --- a/common/src/boilerplate-api.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RestApi } from '@furystack/rest' -import type { User } from './models/index.js' - -export type TestQueryEndpoint = { query: { param1: string }; result: { param1Value: string } } -export type TestUrlParamsEndpoint = { url: { urlParam: string }; result: { urlParamValue: string } } -export type TestPostBodyEndpoint = { body: { value: string }; result: { bodyValue: string } } - -export interface StackCraftApi extends RestApi { - GET: { - '/isAuthenticated': { result: { isAuthenticated: boolean } } - '/currentUser': { result: User } - '/testQuery': TestQueryEndpoint - '/testUrlParams/:urlParam': TestUrlParamsEndpoint - } - POST: { - '/login': { result: User; body: { username: string; password: string } } - '/logout': { result: unknown } - '/testPostBody': TestPostBodyEndpoint - } -} diff --git a/common/src/index.ts b/common/src/index.ts index 227ee11..ff71825 100644 --- a/common/src/index.ts +++ b/common/src/index.ts @@ -1,2 +1,4 @@ export * from './models/index.js' -export * from './stack-craft-api.js' +export * from './apis/index.js' +export * from './websocket/index.js' +export * from './utils/service-path-utils.js' diff --git a/common/src/models/api-token.ts b/common/src/models/api-token.ts new file mode 100644 index 0000000..514f67e --- /dev/null +++ b/common/src/models/api-token.ts @@ -0,0 +1,8 @@ +export class ApiToken { + id!: string + username!: string + name!: string + tokenHash!: string + lastUsedAt?: string + createdAt!: string +} diff --git a/common/src/models/dependency.ts b/common/src/models/dependency.ts new file mode 100644 index 0000000..e091144 --- /dev/null +++ b/common/src/models/dependency.ts @@ -0,0 +1,24 @@ +/** + * Shareable dependency definition. + * Describes an external prerequisite (e.g. Node.js, Docker) that services may require. + * Included in stack exports and shared between installations. + */ +export class Dependency { + /** UUID primary key */ + id!: string + + /** FK to {@link StackDefinition.name} */ + stackName!: string + + /** Human-readable dependency name (e.g. "Node.js") */ + name!: string + + /** Shell command to check if the dependency is satisfied (e.g. "node --version") */ + checkCommand!: string + + /** Help text shown when the dependency check fails */ + installationHelp: string = '' + + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/github-repository.ts b/common/src/models/github-repository.ts new file mode 100644 index 0000000..0e05d16 --- /dev/null +++ b/common/src/models/github-repository.ts @@ -0,0 +1,24 @@ +/** + * Shareable GitHub repository definition. + * Links a git repository to a stack for cloning and pulling. + * Included in stack exports and shared between installations. + */ +export class GitHubRepository { + /** UUID primary key */ + id!: string + + /** FK to {@link StackDefinition.name} */ + stackName!: string + + /** Full URL to the git repository (e.g. "https://github.com/user/repo") */ + url!: string + + /** Human-readable name shown in the UI */ + displayName!: string + + /** Optional description */ + description: string = '' + + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/index.ts b/common/src/models/index.ts index 72e50f8..647e6cb 100644 --- a/common/src/models/index.ts +++ b/common/src/models/index.ts @@ -1 +1,13 @@ export * from './user.js' +export * from './stack-definition.js' +export * from './stack-config.js' +export * from './service-definition.js' +export * from './service-config.js' +export * from './service-status.js' +export * from './service-state-history.js' +export * from './service-log-entry.js' +export * from './github-repository.js' +export * from './dependency.js' +export * from './api-token.js' +export * from './public-api-token.js' +export * from './views.js' diff --git a/common/src/models/public-api-token.ts b/common/src/models/public-api-token.ts new file mode 100644 index 0000000..0b65c80 --- /dev/null +++ b/common/src/models/public-api-token.ts @@ -0,0 +1,7 @@ +export class PublicApiToken { + id!: string + username!: string + name!: string + lastUsedAt?: string + createdAt!: string +} diff --git a/common/src/models/service-config.ts b/common/src/models/service-config.ts new file mode 100644 index 0000000..1cc67c7 --- /dev/null +++ b/common/src/models/service-config.ts @@ -0,0 +1,22 @@ +/** + * User-specific service configuration. + * Contains settings that each installation can customize independently. + * Not included in exports - set by the user during import/installation. + * @see ServiceDefinition for the shareable definition + */ +export class ServiceConfig { + /** FK to {@link ServiceDefinition.id} */ + serviceId!: string + + /** Whether automatic git fetch is enabled */ + autoFetchEnabled: boolean = false + + /** Interval in minutes between automatic git fetches */ + autoFetchIntervalMinutes: number = 60 + + /** Whether to automatically restart the service when new commits are fetched */ + autoRestartOnFetch: boolean = false + + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/service-definition.ts b/common/src/models/service-definition.ts new file mode 100644 index 0000000..9b11fc6 --- /dev/null +++ b/common/src/models/service-definition.ts @@ -0,0 +1,44 @@ +/** + * Shareable service definition. + * Contains the immutable description of a service and its commands. + * Included in stack exports and shared between installations. + * @see ServiceConfig for user-specific configuration + * @see ServiceStatus for runtime state + */ +export class ServiceDefinition { + /** UUID primary key */ + id!: string + + /** FK to {@link StackDefinition.name} */ + stackName!: string + + /** Human-readable name shown in the UI */ + displayName!: string + + /** Optional description of what this service does */ + description: string = '' + + /** Optional relative path within stack for grouping (e.g. "frontends/public") */ + workingDirectory?: string + + /** Optional FK to {@link GitHubRepository.id} */ + repositoryId?: string + + /** IDs of {@link Dependency} entities required by this service */ + dependencyIds: string[] = [] + + /** IDs of other {@link ServiceDefinition} entities that must be running first */ + prerequisiteServiceIds: string[] = [] + + /** Shell command to install dependencies (e.g. "npm install") */ + installCommand?: string + + /** Shell command to build the service (e.g. "npm run build") */ + buildCommand?: string + + /** Shell command to run the service (e.g. "npm start") */ + runCommand!: string + + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/service-log-entry.ts b/common/src/models/service-log-entry.ts new file mode 100644 index 0000000..2fe3ddd --- /dev/null +++ b/common/src/models/service-log-entry.ts @@ -0,0 +1,8 @@ +export class ServiceLogEntry { + id!: number + serviceId!: string + processUid!: string + stream!: 'stdout' | 'stderr' + line!: string + createdAt!: string +} diff --git a/common/src/models/service-state-history.ts b/common/src/models/service-state-history.ts new file mode 100644 index 0000000..ded9f7d --- /dev/null +++ b/common/src/models/service-state-history.ts @@ -0,0 +1,56 @@ +/** + * Event types for service state transitions. + * Each value represents a discrete lifecycle event that can occur. + */ +export type ServiceStateEvent = + | 'run-started' + | 'run-stopped' + | 'run-crashed' + | 'run-restarted' + | 'install-started' + | 'install-completed' + | 'install-failed' + | 'build-started' + | 'build-completed' + | 'build-failed' + | 'pull-completed' + +/** + * How a state change was triggered. + * Used to distinguish user actions from automated system behavior. + */ +export type TriggerSource = 'api' | 'mcp' | 'auto-fetch' | 'auto-restart' | 'system' + +/** + * Audit log entry for service state transitions. + * Records every lifecycle event (start, stop, crash, install, build, pull) + * with full context: who triggered it, how, and any relevant metadata. + * Entries are never deleted and not included in exports. + */ +export class ServiceStateHistory { + /** Auto-increment primary key */ + id!: number + + /** FK to {@link ServiceDefinition.id} */ + serviceId!: string + + /** The lifecycle event that occurred */ + event!: ServiceStateEvent + + /** JSON snapshot of relevant status fields before the change */ + previousState?: string + + /** JSON snapshot of relevant status fields after the change */ + newState?: string + + /** Username of the user who triggered the action, or 'system' */ + triggeredBy!: string + + /** How the action was triggered */ + triggerSource!: TriggerSource + + /** Optional JSON with extra context (exit code, error message, etc.) */ + metadata?: string + + createdAt!: string +} diff --git a/common/src/models/service-status.ts b/common/src/models/service-status.ts new file mode 100644 index 0000000..c7687cb --- /dev/null +++ b/common/src/models/service-status.ts @@ -0,0 +1,26 @@ +export type InstallStatus = 'not-installed' | 'installing' | 'installed' | 'failed' +export type BuildStatus = 'not-built' | 'building' | 'built' | 'failed' +export type RunStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'error' + +/** + * Runtime status of a service. + * Managed by the system (ProcessManager, GitWatcher). + * Never exported. Reset to defaults on import. + * @see ServiceDefinition for the shareable definition + * @see ServiceStateHistory for the audit log of state transitions + */ +export class ServiceStatus { + /** FK to {@link ServiceDefinition.id} */ + serviceId!: string + + installStatus: InstallStatus = 'not-installed' + buildStatus: BuildStatus = 'not-built' + runStatus: RunStatus = 'stopped' + + lastInstalledAt?: string + lastBuiltAt?: string + lastStartedAt?: string + lastFetchedAt?: string + + updatedAt!: string +} diff --git a/common/src/models/stack-config.ts b/common/src/models/stack-config.ts new file mode 100644 index 0000000..dce6d16 --- /dev/null +++ b/common/src/models/stack-config.ts @@ -0,0 +1,16 @@ +/** + * User-specific stack configuration. + * Contains settings unique to this installation/machine. + * Not included in exports - set by the user during import/installation. + * @see StackDefinition for the shareable definition + */ +export class StackConfig { + /** FK to {@link StackDefinition.name} */ + stackName!: string + + /** Absolute path to the root directory for all services in this stack */ + mainDirectory!: string + + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/stack-definition.ts b/common/src/models/stack-definition.ts new file mode 100644 index 0000000..71ea4e8 --- /dev/null +++ b/common/src/models/stack-definition.ts @@ -0,0 +1,19 @@ +/** + * Shareable stack definition. + * Contains the immutable identity and description of a stack. + * Included in stack exports and shared between installations. + * @see StackConfig for user-specific configuration + */ +export class StackDefinition { + /** Unique kebab-case identifier for the stack */ + name!: string + + /** Human-readable name shown in the UI */ + displayName!: string + + /** Optional description of what this stack does */ + description: string = '' + + createdAt!: string + updatedAt!: string +} diff --git a/common/src/models/views.ts b/common/src/models/views.ts new file mode 100644 index 0000000..ab6a3b5 --- /dev/null +++ b/common/src/models/views.ts @@ -0,0 +1,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 { 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 diff --git a/common/src/stack-craft-api.ts b/common/src/stack-craft-api.ts deleted file mode 100644 index 39380ad..0000000 --- a/common/src/stack-craft-api.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RestApi } from '@furystack/rest' -import type { User } from './models/index.js' - -export type TestQueryEndpoint = { query: { param1: string }; result: { param1Value: string } } -export type TestUrlParamsEndpoint = { url: { urlParam: string }; result: { urlParamValue: string } } -export type TestPostBodyEndpoint = { body: { value: string }; result: { bodyValue: string } } - -export interface StackCraftApi extends RestApi { - GET: { - '/isAuthenticated': { result: { isAuthenticated: boolean } } - '/currentUser': { result: User } - '/testQuery': TestQueryEndpoint - '/testUrlParams/:urlParam': TestUrlParamsEndpoint - } - POST: { - '/login': { result: User; body: { username: string; password: string } } - '/logout': { result: unknown } - '/testPostBody': TestPostBodyEndpoint - } -} diff --git a/common/src/utils/service-path-utils.spec.ts b/common/src/utils/service-path-utils.spec.ts new file mode 100644 index 0000000..8a3d9e1 --- /dev/null +++ b/common/src/utils/service-path-utils.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { getRepoNameFromUrl, getServiceCwd } from './service-path-utils.js' +import type { GitHubRepository } from '../models/index.js' + +describe('service-path-utils', () => { + describe('getRepoNameFromUrl', () => { + it('should extract repo name from .git URL', () => { + expect(getRepoNameFromUrl('https://github.com/user/my-repo.git')).toBe('my-repo') + }) + + it('should extract repo name from URL without .git', () => { + expect(getRepoNameFromUrl('https://github.com/user/my-repo')).toBe('my-repo') + }) + + it('should return "repo" for malformed URL', () => { + expect(getRepoNameFromUrl('invalid')).toBe('repo') + }) + }) + + describe('getServiceCwd', () => { + const config = { mainDirectory: '/workspace/stacks/my-stack' } + + it('should join stack root with service path and repo name when repo is linked', () => { + const service = { workingDirectory: 'frontends/public' } + const repo: GitHubRepository = { + id: 'repo-1', + stackName: 'my-stack', + url: 'https://github.com/org/my-frontend.git', + displayName: 'My Frontend', + description: '', + createdAt: '', + updatedAt: '', + } + expect(getServiceCwd(config, service, repo)).toBe('/workspace/stacks/my-stack/frontends/public/my-frontend') + }) + + it('should use stack root when service has no workingDirectory and no repo', () => { + const service = {} + expect(getServiceCwd(config, service, null)).toBe('/workspace/stacks/my-stack') + }) + + it('should use stack root + service path when no repo', () => { + const service = { workingDirectory: 'services/gateway' } + expect(getServiceCwd(config, service, null)).toBe('/workspace/stacks/my-stack/services/gateway') + }) + }) +}) diff --git a/common/src/utils/service-path-utils.ts b/common/src/utils/service-path-utils.ts new file mode 100644 index 0000000..fcb662b --- /dev/null +++ b/common/src/utils/service-path-utils.ts @@ -0,0 +1,38 @@ +import type { GitHubRepository } from '../models/index.js' + +function joinPath(...parts: Array): string { + return parts + .filter((p): p is string => typeof p === 'string' && p !== '') + .map((p) => p.replace(/\\/g, '/')) + .join('/') + .replace(/\/+/g, '/') +} + +/** + * Extracts the repository name from a Git URL. + * e.g. "https://github.com/user/my-repo.git" -> "my-repo" + */ +export function getRepoNameFromUrl(url: string): string { + const match = url.match(/\/([^/]+?)(?:\.git)?$/i) + return match ? match[1] : 'repo' +} + +/** + * Computes the full working directory (CWD) for a service. + * Formula: join(mainDirectory, workingDirectory ?? '', repoName when cloned) + * + * - mainDirectory comes from {@link StackConfig} and is the root for all processes in the stack + * - workingDirectory comes from {@link ServiceDefinition} and is optional, for grouping + * - When cloning from Git, the repo name is added as a subdirectory + */ +export function getServiceCwd( + config: { mainDirectory: string }, + service: { workingDirectory?: string }, + repo?: GitHubRepository | null, +): string { + const base = joinPath(config.mainDirectory, service.workingDirectory) + if (repo?.url) { + return joinPath(base, getRepoNameFromUrl(repo.url)) + } + return base +} diff --git a/common/src/websocket/index.ts b/common/src/websocket/index.ts new file mode 100644 index 0000000..275e5ad --- /dev/null +++ b/common/src/websocket/index.ts @@ -0,0 +1,21 @@ +import type { InstallStatus, BuildStatus, RunStatus } from '../models/service-status.js' + +export type WebsocketMessage = + | { + type: 'service-status-changed' + serviceId: string + 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/e2e/page.spec.ts b/e2e/page.spec.ts index b1be8f4..6bc89e2 100644 --- a/e2e/page.spec.ts +++ b/e2e/page.spec.ts @@ -1,38 +1,81 @@ import { expect, test } from '@playwright/test' -test.describe('Example Application', () => { - test('Login and logout roundtrip', async ({ page }) => { +test.describe('StackCraft MVP Flow', () => { + test('should complete the installer wizard', async ({ page }) => { + await page.goto('/') + + const installerOrLogin = page.locator('shade-lazy-installer, shade-login') + await expect(installerOrLogin.first()).toBeVisible({ timeout: 15000 }) + + const isInstaller = (await page.locator('shade-lazy-installer').count()) > 0 + if (!isInstaller) { + return + } + + const nextButton = page.locator('button', { hasText: 'Next' }) + + await expect(page.locator('text=Welcome')).toBeVisible({ timeout: 10000 }) + await nextButton.click() + + await expect(page.locator('text=Prerequisites')).toBeVisible({ timeout: 5000 }) + await nextButton.click() + + await expect(page.locator('text=Admin')).toBeVisible({ timeout: 5000 }) + const usernameInput = page.locator('input[name="userName"]') + const passwordInput = page.locator('input[name="password"]') + await usernameInput.fill('testuser') + await passwordInput.fill('password') + await page.locator('button', { hasText: 'Create' }).click() + + await expect(page.locator('text=Success')).toBeVisible({ timeout: 10000 }) + await page.locator('button', { hasText: 'Finish' }).click() + }) + + test('Login and view dashboard', async ({ page }) => { await page.goto('/') const loginForm = page.locator('shade-login form') - await expect(loginForm).toBeVisible() + await expect(loginForm).toBeVisible({ timeout: 15000 }) + + await loginForm.locator('input[name="userName"]').fill('testuser') + await loginForm.locator('input[name="password"]').fill('password') + await page.locator('button', { hasText: 'Login' }).click() + + await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10000 }) + }) + + test('Create stack, create service, verify dashboard', async ({ page }) => { + await page.goto('/') - const usernameInput = loginForm.locator('input[name="userName"]') - await expect(usernameInput).toBeVisible() + const loginForm = page.locator('shade-login form') + await expect(loginForm).toBeVisible({ timeout: 15000 }) + await loginForm.locator('input[name="userName"]').fill('testuser') + await loginForm.locator('input[name="password"]').fill('password') + await page.locator('button', { hasText: 'Login' }).click() - const passwordInput = loginForm.locator('input[name="password"]') - await expect(passwordInput).toBeVisible() + await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10000 }) - await usernameInput.type('testuser') - await passwordInput.type('password') + await page.locator('button', { hasText: 'Create Stack' }).first().click() + await expect(page.locator('shade-create-stack')).toBeVisible({ timeout: 5000 }) - const submitButton = page.locator('button', { hasText: 'Login' }) - await expect(submitButton).toBeVisible() - await expect(submitButton).toBeEnabled() + await page.locator('input[name="name"]').fill('e2e-test-stack') + await page.locator('input[name="displayName"]').fill('E2E Test Stack') + await page.locator('input[name="description"]').fill('Created by E2E test') + await page.locator('input[name="mainDirectory"]').fill('/tmp/e2e-test') + await page.locator('button', { hasText: 'Create' }).click() - await submitButton.click() + await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('text=E2E Test Stack')).toBeVisible({ timeout: 5000 }) - const welcomeTitle = page.locator('hello-world div h2') - await expect(welcomeTitle).toBeVisible() - await expect(welcomeTitle).toHaveText('Hello, testuser !') + await page.locator('button', { hasText: 'Create Service' }).first().click() + await expect(page.locator('shade-create-service')).toBeVisible({ timeout: 5000 }) - const logoutButton = page.locator('shade-app-bar button >> text="Log Out"') - await expect(logoutButton).toBeVisible() - await expect(logoutButton).toBeEnabled() - await expect(logoutButton).toHaveText('Log Out') - await logoutButton.click() + await page.locator('input[name="displayName"]').fill('E2E Service') + await page.locator('input[name="workingDirectory"]').fill('svc') + await page.locator('input[name="runCommand"]').fill('echo hello') + await page.locator('button[type="submit"]').click() - const loggedOutLoginForm = page.locator('shade-login form') - await expect(loggedOutLoginForm).toBeVisible() + await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10000 }) + await expect(page.locator('text=E2E Service')).toBeVisible({ timeout: 5000 }) }) }) diff --git a/frontend/package.json b/frontend/package.json index 74d4989..38c018e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,19 +12,22 @@ "license": "ISC", "devDependencies": { "@codecov/vite-plugin": "^1.9.1", + "@types/node": "^25.3.0", "typescript": "^5.9.3", "vite": "^7.3.1", "vitest": "^4.0.18" }, "dependencies": { - "@furystack/core": "^15.0.36", + "@furystack/cache": "^6.0.0", + "@furystack/core": "^15.1.0", + "@furystack/entity-sync": "^0.1.1", + "@furystack/entity-sync-client": "^0.1.1", "@furystack/inject": "^12.0.30", "@furystack/logging": "^8.0.30", - "@furystack/rest-client-fetch": "^8.0.36", - "@furystack/shades": "^12.0.1", - "@furystack/shades-common-components": "^12.1.0", + "@furystack/rest-client-fetch": "^8.0.37", + "@furystack/shades": "^12.1.0", + "@furystack/shades-common-components": "^12.3.0", "@furystack/utils": "^8.1.10", - "@types/node": "^25.2.3", "common": "workspace:^" } } diff --git a/frontend/src/components/body.tsx b/frontend/src/components/body.tsx index 74cefe8..ad0f2c9 100644 --- a/frontend/src/components/body.tsx +++ b/frontend/src/components/body.tsx @@ -1,8 +1,63 @@ -import { createComponent, Router, Shade } from '@furystack/shades' -import { ButtonsDemo, HelloWorld, Init, Login, Offline } from '../pages/index.js' +import type { Injector } from '@furystack/inject' +import { createComponent, NestedRouter, Shade } from '@furystack/shades' +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 { Init, Offline } from '../pages/index.js' +import { CreateRepository } from '../pages/repositories/create-repository.js' +import { EditRepository } from '../pages/repositories/edit-repository.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 { UserSettings } from '../pages/settings/user-settings.js' +import { CreateStack } from '../pages/stacks/create-stack.js' +import { EditStack } from '../pages/stacks/edit-stack.js' +import { CreateServiceWizard } from '../pages/wizards/create-service-wizard.js' import { SessionService } from '../services/session.js' -export const Body = Shade<{ style?: Partial }>({ +const appRoutes = { + '/': { + component: () => , + }, + '/services/create/:stackName': { + component: ({ match }) => , + }, + '/services/wizard/:stackName': { + component: ({ match }) => , + }, + '/services/:id/logs': { + component: ({ match }) => , + }, + '/services/:id': { + component: ({ match }) => , + }, + '/repositories/create/:stackName': { + component: ({ match }) => , + }, + '/repositories/:id': { + component: ({ match }) => , + }, + '/settings': { + component: () => , + }, + '/stacks/create': { + component: () => , + }, + '/stacks/import': { + component: () => , + }, + '/stacks/:name/edit': { + component: ({ match }) => , + }, + '/stacks/:name': { + component: ({ match }) => , + }, + '/stacks/:name/export': { + component: ({ match }) => , + }, +} satisfies Record } }) => JSX.Element }> + +export const Body = Shade<{ style?: Partial; injector?: Injector }>({ shadowDomName: 'shade-app-body', render: ({ injector, useObservable }) => { const session = injector.getInstance(SessionService) @@ -12,18 +67,9 @@ export const Body = Shade<{ style?: Partial }>({ {(() => { switch (sessionState) { case 'authenticated': - return ( - }, - { url: '/', routingOptions: { end: false }, component: () => }, - ]} - > - ) + return } /> case 'offline': return - case 'unauthenticated': - return default: return } diff --git a/frontend/src/components/confirm-dialog.tsx b/frontend/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..25c7b94 --- /dev/null +++ b/frontend/src/components/confirm-dialog.tsx @@ -0,0 +1,62 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Button, cssVariableTheme, Paper } from '@furystack/shades-common-components' + +type ConfirmDialogProps = { + title: string + message: string + confirmLabel?: string + cancelLabel?: string + variant?: 'danger' | 'default' + onConfirm: () => void + onCancel: () => void +} + +export const ConfirmDialog = Shade({ + shadowDomName: 'shade-confirm-dialog', + render: ({ props }) => { + const confirmLabel = props.confirmLabel ?? 'Confirm' + const cancelLabel = props.cancelLabel ?? 'Cancel' + const isDanger = props.variant === 'danger' + + return ( +
{ + if (ev.target === ev.currentTarget) { + props.onCancel() + } + }} + > + +

{props.title}

+

{props.message}

+
+ + +
+
+
+ ) + }, +}) diff --git a/frontend/src/components/entity-forms/dependency-form.tsx b/frontend/src/components/entity-forms/dependency-form.tsx new file mode 100644 index 0000000..7718951 --- /dev/null +++ b/frontend/src/components/entity-forms/dependency-form.tsx @@ -0,0 +1,66 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Button, Input } from '@furystack/shades-common-components' +import type { Dependency } from 'common' + +type DependencyFormProps = { + initial?: Partial + stackName: string + onSubmit: (data: Partial) => void | Promise + onCancel: () => void + mode: 'create' | 'edit' +} + +export const DependencyForm = Shade({ + shadowDomName: 'shade-dependency-form', + render: ({ props }) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.target as HTMLFormElement) + const data = Object.fromEntries(formData.entries()) as Record + await props.onSubmit({ + stackName: props.stackName, + name: data.name, + checkCommand: data.checkCommand, + installationHelp: data.installationHelp, + }) + }} + > +

{props.mode === 'create' ? 'Add Dependency' : 'Edit Dependency'}

+ 'e.g., Node.js, Python, Docker'} + /> + 'Command that returns exit code 0 if installed (e.g., node --version)'} + /> + 'Instructions for installing this dependency'} + /> +
+ + +
+ + ) + }, +}) diff --git a/frontend/src/components/entity-forms/github-repo-form.tsx b/frontend/src/components/entity-forms/github-repo-form.tsx new file mode 100644 index 0000000..6a77731 --- /dev/null +++ b/frontend/src/components/entity-forms/github-repo-form.tsx @@ -0,0 +1,64 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Button, Input } from '@furystack/shades-common-components' +import type { GitHubRepository } from 'common' + +type GitHubRepoFormProps = { + initial?: Partial + stackName: string + onSubmit: (data: Partial) => void | Promise + onCancel: () => void + mode: 'create' | 'edit' +} + +export const GitHubRepoForm = Shade({ + shadowDomName: 'shade-github-repo-form', + render: ({ props }) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.target as HTMLFormElement) + const data = Object.fromEntries(formData.entries()) as Record + await props.onSubmit({ + stackName: props.stackName, + url: data.url, + displayName: data.displayName, + description: data.description, + }) + }} + > +

{props.mode === 'create' ? 'Add GitHub Repository' : 'Edit Repository'}

+ 'Full GitHub URL, e.g. https://github.com/org/repo'} + /> + + +
+ + +
+ + ) + }, +}) diff --git a/frontend/src/components/entity-forms/service-form.tsx b/frontend/src/components/entity-forms/service-form.tsx new file mode 100644 index 0000000..8782104 --- /dev/null +++ b/frontend/src/components/entity-forms/service-form.tsx @@ -0,0 +1,130 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Button, Input, Select } from '@furystack/shades-common-components' +import type { GitHubRepository, ServiceView } from 'common' + +type ServiceFormProps = { + initial?: Partial + stackName: string + repositories?: GitHubRepository[] + onSubmit: (data: Partial) => void | Promise + onCancel: () => void + mode: 'create' | 'edit' +} + +export const ServiceForm = Shade({ + shadowDomName: 'shade-service-form', + render: ({ props }) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.target as HTMLFormElement) + const data = Object.fromEntries(formData.entries()) as Record + await props.onSubmit({ + stackName: props.stackName, + displayName: data.displayName, + description: data.description, + workingDirectory: data.workingDirectory || undefined, + repositoryId: data.repositoryId || undefined, + runCommand: data.runCommand, + installCommand: data.installCommand || undefined, + buildCommand: data.buildCommand || undefined, + autoFetchEnabled: data.autoFetchEnabled === 'on', + autoFetchIntervalMinutes: parseInt(data.autoFetchIntervalMinutes, 10) || 60, + autoRestartOnFetch: data.autoRestartOnFetch === 'on', + }) + }} + > +

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

+ +

Definition

+ + + {props.repositories && props.repositories.length > 0 ? ( + + 'Optional. Relative path within stack for grouping, e.g. frontends/public or services/gateways' + } + /> + 'e.g., npm start, yarn dev, dotnet run'} + /> + 'e.g., npm install, yarn, dotnet restore'} + /> + 'e.g., npm run build, yarn build, dotnet build'} + /> +

Configuration

+
+ + + +
+
+ + +
+ + ) + }, +}) diff --git a/frontend/src/components/entity-forms/stack-form.tsx b/frontend/src/components/entity-forms/stack-form.tsx new file mode 100644 index 0000000..6b8274a --- /dev/null +++ b/frontend/src/components/entity-forms/stack-form.tsx @@ -0,0 +1,76 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Button, Input } from '@furystack/shades-common-components' +import type { StackView } from 'common' + +type StackFormProps = { + initial?: Partial + onSubmit: (data: Partial) => void | Promise + onCancel: () => void + mode: 'create' | 'edit' +} + +export const StackForm = Shade({ + shadowDomName: 'shade-stack-form', + render: ({ props }) => { + return ( +
{ + ev.preventDefault() + const formData = new FormData(ev.target as HTMLFormElement) + const data = Object.fromEntries(formData.entries()) as Record + await props.onSubmit({ + name: data.name, + displayName: data.displayName, + description: data.description, + mainDirectory: data.mainDirectory, + }) + }} + > +

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

+ +

Definition

+ 'Unique kebab-case identifier'} + /> + + + +

Configuration

+ 'Absolute path to the root directory for this stack'} + /> +
+ + +
+ + ) + }, +}) diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index a0faf4b..de95acb 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,39 +1,27 @@ -import { createComponent, RouteLink, Shade } from '@furystack/shades' -import { AppBar, Button } from '@furystack/shades-common-components' -import { environmentOptions } from '../environment-options.js' +import { createComponent, Shade } from '@furystack/shades' +import { AppBar, AppBarLink, Button, DrawerToggleButton } from '@furystack/shades-common-components' import { SessionService } from '../services/session.js' -import { GithubLogo } from './github-logo/index.js' import { ThemeSwitch } from './theme-switch/index.js' -export interface HeaderProps { +export type HeaderProps = { title: string - links: Array<{ name: string; url: string }> } export const Header = Shade({ shadowDomName: 'shade-app-header', css: { '& h3': { - margin: '0 2em 0 0', + margin: '0 1em 0 0', cursor: 'pointer', }, - '& route-link': { - color: '#aaa', - textDecoration: 'none', - cursor: 'pointer', - }, - '& route-link:hover': { - color: '#fff', - }, - '& .nav-link': { - padding: '0 8px', - }, '& .spacer': { flex: '1', }, '& .actions': { display: 'flex', placeContent: 'center', + alignItems: 'center', + gap: '8px', marginRight: '24px', }, }, @@ -42,24 +30,21 @@ export const Header = Shade({ return ( +

- + {props.title} - +

- {props.links.map((link) => ( - - {link.name || ''} - - ))} + {sessionState === 'authenticated' ? ( +
+ Dashboard + Settings +
+ ) : null}
- - - {sessionState === 'authenticated' ? ( + ), + }} + /> + ) + }, +}) diff --git a/frontend/src/components/service-status-indicator.tsx b/frontend/src/components/service-status-indicator.tsx new file mode 100644 index 0000000..15564ac --- /dev/null +++ b/frontend/src/components/service-status-indicator.tsx @@ -0,0 +1,32 @@ +import { createComponent, Shade } from '@furystack/shades' +import { Chip } from '@furystack/shades-common-components' +import type { Palette } from '@furystack/shades-common-components' +import type { ServiceView } from 'common' + +type ServiceStatusIndicatorProps = { + service: ServiceView +} + +const getAggregateStatus = (svc: ServiceView): { label: string; color: keyof Palette } => { + if (svc.runStatus === 'running') return { label: 'Running', color: 'success' } + if (svc.runStatus === 'starting') return { label: 'Starting', color: 'warning' } + if (svc.runStatus === 'stopping') return { label: 'Stopping', color: 'warning' } + if (svc.runStatus === 'error') return { label: 'Error', color: 'error' } + if (svc.installStatus === 'installing') return { label: 'Installing', color: 'warning' } + if (svc.installStatus === 'failed') return { label: 'Install Failed', color: 'error' } + if (svc.buildStatus === 'building') return { label: 'Building', color: 'warning' } + if (svc.buildStatus === 'failed') return { label: 'Build Failed', color: 'error' } + return { label: 'Stopped', color: 'secondary' } +} + +export const ServiceStatusIndicator = Shade({ + shadowDomName: 'shade-service-status-indicator', + render: ({ props }) => { + const { label, color } = getAggregateStatus(props.service) + return ( + + {label} + + ) + }, +}) diff --git a/frontend/src/components/service-table.tsx b/frontend/src/components/service-table.tsx new file mode 100644 index 0000000..a4ab7d4 --- /dev/null +++ b/frontend/src/components/service-table.tsx @@ -0,0 +1,115 @@ +import type { FindOptions } from '@furystack/core' +import { createComponent, Shade } from '@furystack/shades' +import { Button, CollectionService, DataGrid, Icon, icons, SelectionCell } from '@furystack/shades-common-components' +import { ObservableValue } from '@furystack/utils' +import type { ServiceView } from 'common' + +import { ServicesApiClient } from '../services/api-clients/services-api-client.js' +import { ServiceStatusIndicator } from './service-status-indicator.js' + +type ServiceTableProps = { + services: ServiceView[] + onViewLogs: (serviceId: string) => void + onDetails: (serviceId: string) => void + onEdit: (serviceId: string) => void + onSelectionChange?: (selected: ServiceView[]) => void +} + +type ServiceColumn = 'selection' | 'displayName' | 'runStatus' | 'actions' + +export const ServiceTable = Shade({ + shadowDomName: 'shade-service-table', + render: ({ props, injector, useDisposable, useObservable }) => { + const api = injector.getInstance(ServicesApiClient) + + const collectionService = useDisposable( + 'collectionService', + () => new CollectionService({ searchField: 'displayName' }), + ) + + const findOptions = useDisposable( + 'findOptions', + () => new ObservableValue>>({}), + ) + + collectionService.data.setValue({ entries: props.services, count: props.services.length }) + + const [selectedServices] = useObservable('selection', collectionService.selection) + props.onSelectionChange?.(selectedServices) + + return ( + + columns={['selection', 'displayName', 'runStatus', 'actions']} + findOptions={findOptions} + styles={undefined} + collectionService={collectionService} + headerComponents={{ + selection: () => , + actions: () => Actions, + }} + rowComponents={{ + selection: (entry) => , + displayName: (entry) => ( + + {entry.displayName} + {entry.description ? ( +
{entry.description}
+ ) : null} +
+ ), + runStatus: (entry) => , + actions: (entry) => ( +
e.stopPropagation()} + > + {entry.runStatus !== 'running' ? ( +
+ ), + }} + /> + ) + }, +}) diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx new file mode 100644 index 0000000..49d3fe5 --- /dev/null +++ b/frontend/src/components/sidebar.tsx @@ -0,0 +1,237 @@ +import { useCollectionSync } from '@furystack/entity-sync-client' +import type { Injector } from '@furystack/inject' +import { createComponent, LocationService, NestedRouteLink, Shade } from '@furystack/shades' +import { cssVariableTheme, Divider, Icon, icons } from '@furystack/shades-common-components' +import { StackDefinition } from 'common' +import type { StackView } from 'common' +import { match } from 'path-to-regexp' + +type SidebarStackLinkProps = { + stackName: string + href: string + label: string + currentUrl: string +} + +const SidebarStackLink = Shade({ + shadowDomName: 'shade-sidebar-stack-link', + css: { + display: 'block', + '& a': { + display: 'block', + padding: '7px 16px 7px 44px', + textDecoration: 'none', + color: 'inherit', + fontSize: '0.84rem', + borderRadius: cssVariableTheme.shape.borderRadius.sm, + borderLeft: '3px solid transparent', + margin: '1px 8px 1px 0', + transition: `background ${cssVariableTheme.transitions.duration.fast} ease, border-color ${cssVariableTheme.transitions.duration.fast} ease, color ${cssVariableTheme.transitions.duration.fast} ease`, + }, + '& a:hover': { + background: cssVariableTheme.action.hoverBackground, + }, + '& a[data-active]': { + color: cssVariableTheme.palette.primary.main, + fontWeight: cssVariableTheme.typography.fontWeight.semibold, + background: cssVariableTheme.action.hoverBackground, + borderLeftColor: cssVariableTheme.palette.primary.main, + }, + }, + render: ({ props }) => { + const isActive = !!match(props.href, { end: true })(props.currentUrl) + return ( + + {props.label} + + ) + }, +}) + +type SidebarStackCategoryProps = { + stack: StackView + currentUrl: string +} + +const SidebarStackCategory = Shade({ + shadowDomName: 'shade-sidebar-stack-category', + css: { + display: 'block', + marginBottom: '2px', + '& .category-header': { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '8px 12px', + cursor: 'pointer', + fontSize: '0.82rem', + fontWeight: '500', + letterSpacing: '0.02em', + userSelect: 'none', + borderRadius: cssVariableTheme.shape.borderRadius.sm, + margin: '0 8px', + transition: `background ${cssVariableTheme.transitions.duration.fast} ease, color ${cssVariableTheme.transitions.duration.fast} ease`, + }, + '& .category-header:hover': { + background: cssVariableTheme.action.hoverBackground, + }, + '& .category-header[data-active]': { + color: cssVariableTheme.palette.primary.main, + fontWeight: '600', + }, + '& .expand-arrow': { + fontSize: '0.55rem', + width: '12px', + textAlign: 'center', + transition: `transform ${cssVariableTheme.transitions.duration.normal} ease`, + display: 'inline-block', + }, + '& .expand-arrow[data-expanded]': { + transform: 'rotate(90deg)', + }, + '& .category-children': { + paddingBottom: '4px', + }, + }, + render: ({ props, useState }) => { + const stackPrefix = `/stacks/${props.stack.name}` + const isCategoryActive = props.currentUrl === stackPrefix || props.currentUrl.startsWith(`${stackPrefix}/`) + + const [isExpanded, setIsExpanded] = useState('isExpanded', isCategoryActive) + + if (isCategoryActive && !isExpanded) { + setIsExpanded(true) + } + + return ( +
+
setIsExpanded(!isExpanded)} + > + + ▶ + + + {props.stack.displayName} +
+ {isExpanded ? ( +
+ +
+ ) : null} +
+ ) + }, +}) + +type SidebarItemProps = { + href: string + icon: typeof icons.home + label: string + currentUrl: string +} + +const SidebarItem = Shade({ + shadowDomName: 'shade-sidebar-item', + css: { + display: 'block', + + '& a': { + display: 'flex', + alignItems: 'center', + gap: cssVariableTheme.spacing.sm, + padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.md}`, + borderRadius: cssVariableTheme.shape.borderRadius.md, + color: cssVariableTheme.text.secondary, + textDecoration: 'none', + fontSize: cssVariableTheme.typography.fontSize.md, + transition: `background-color ${cssVariableTheme.transitions.duration.normal} ${cssVariableTheme.transitions.easing.easeInOut}, color ${cssVariableTheme.transitions.duration.normal} ${cssVariableTheme.transitions.easing.easeInOut}`, + cursor: 'pointer', + }, + + '& a:hover': { + backgroundColor: cssVariableTheme.button.hover, + color: cssVariableTheme.text.primary, + }, + + '&[data-active] a': { + backgroundColor: cssVariableTheme.button.hover, + color: cssVariableTheme.text.primary, + fontWeight: cssVariableTheme.typography.fontWeight.semibold, + }, + }, + render: ({ props, useHostProps }) => { + const isActive = !!match(props.href, { end: props.href === '/' })(props.currentUrl) + + if (isActive) { + useHostProps({ 'data-active': '' }) + } + + return ( + + + {props.label} + + ) + }, +}) + +export const Sidebar = Shade<{ injector?: Injector }>({ + shadowDomName: 'shade-sidebar', + css: { + display: 'block', + height: '100%', + overflow: 'hidden auto', + color: cssVariableTheme.text.primary, + scrollbarWidth: 'thin', + scrollbarGutter: 'stable', + '&::-webkit-scrollbar': { + width: '4px', + }, + '&::-webkit-scrollbar-thumb': { + background: 'transparent', + borderRadius: '4px', + }, + '&:hover::-webkit-scrollbar-thumb': { + background: 'rgba(128,128,128,0.4)', + }, + '& .sidebar-section-label': { + padding: '12px 20px 4px', + fontSize: '0.68rem', + fontWeight: '600', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: cssVariableTheme.text.secondary, + userSelect: 'none', + }, + }, + render: (options) => { + const { injector, useObservable } = options + const [currentUrl] = useObservable('locationChange', injector.getInstance(LocationService).onLocationPathChanged) + + const stacksState = useCollectionSync(options, StackDefinition, {}) + const stacks = ( + stacksState.status === 'synced' || stacksState.status === 'cached' ? stacksState.data : [] + ) as StackView[] + + return ( + + ) + }, +}) diff --git a/frontend/src/components/wizard-step.tsx b/frontend/src/components/wizard-step.tsx new file mode 100644 index 0000000..8374363 --- /dev/null +++ b/frontend/src/components/wizard-step.tsx @@ -0,0 +1,48 @@ +import { createComponent, Shade } from '@furystack/shades' +import type { WizardStepProps } from '@furystack/shades-common-components' +import { Button } from '@furystack/shades-common-components' + +export const WizardStep = Shade< + { title: string; onSubmit?: (ev: SubmitEvent) => void | Promise } & WizardStepProps +>({ + shadowDomName: 'shade-wizard-step', + render: ({ props, children }) => { + return ( +
{ + ev.preventDefault() + if (props.onSubmit) { + await props.onSubmit(ev) + } else { + props.onNext?.() + } + }} + > +

{props.title}

+
{children}
+
+ + +
+
+ ) + }, +}) diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx new file mode 100644 index 0000000..4d9b382 --- /dev/null +++ b/frontend/src/pages/dashboard/index.tsx @@ -0,0 +1,224 @@ +import { useCollectionSync } from '@furystack/entity-sync-client' +import { serializeToQueryString } from '@furystack/rest' +import { createComponent, NestedRouteLink, Shade } from '@furystack/shades' + +import { Button, Icon, icons, Loader, PageContainer, PageHeader, Paper } from '@furystack/shades-common-components' +import { GitHubRepository, ServiceDefinition, StackDefinition } from 'common' +import type { ServiceView } from 'common' +import { navigate } from '../../utils/navigate.js' +import { ServicesApiClient } from '../../services/api-clients/services-api-client.js' + +import { RepositoryTable } from '../../components/repository-table.js' +import { ServiceTable } from '../../components/service-table.js' + +type DashboardProps = { + stackName?: string +} + +export const Dashboard = Shade({ + shadowDomName: 'shade-dashboard', + render: (options) => { + const { props, injector } = options + + const stacksState = useCollectionSync(options, StackDefinition, {}) + const stacks = stacksState.status === 'synced' || stacksState.status === 'cached' ? stacksState.data : [] + + const isLoading = stacksState.status === 'connecting' + + if (isLoading) { + return ( + +
+ +
+
+ ) + } + + // When no stackName is provided (route `/`), redirect to the first stack or show empty state + if (!props.stackName) { + if (stacks.length > 0) { + queueMicrotask(() => navigate(injector, `/stacks/${stacks[0].name}`)) + return null + } + + return ( + + + + + + +
+ } + /> + + ) + } + + const currentStack = stacks.find((s) => s.name === props.stackName) + + const servicesState = useCollectionSync(options, ServiceDefinition, { + filter: { stackName: { $eq: props.stackName } }, + }) + const services: ServiceView[] = + servicesState.status === 'synced' || servicesState.status === 'cached' + ? (servicesState.data as ServiceView[]) + : [] + + const reposState = useCollectionSync(options, GitHubRepository, { + filter: { stackName: { $eq: props.stackName } }, + }) + const repos = reposState.status === 'synced' || reposState.status === 'cached' ? reposState.data : [] + + const api = injector.getInstance(ServicesApiClient) + const [selectedServices, setSelectedServices] = options.useState('selectedServices', []) + const [isBulkLoading, setIsBulkLoading] = options.useState('isBulkLoading', false) + + const hasRunning = selectedServices.some((s) => s.runStatus === 'running') + const hasStopped = selectedServices.some((s) => s.runStatus !== 'running') + const hasSelection = selectedServices.length > 0 + + const bulkAction = async (action: string) => { + setIsBulkLoading(true) + for (const svc of selectedServices) { + try { + await api.call({ + method: 'POST', + action: `/services/:id/${action}` as '/services/:id/start', + url: { id: svc.id }, + }) + } catch { + // Individual failures are handled by entity-sync status updates + } + } + setIsBulkLoading(false) + } + + return ( + + } + title={currentStack?.displayName ?? props.stackName} + description={currentStack?.description} + actions={ + + } + /> + + +
+

Services ({services.length})

+ {hasSelection ? ( + {selectedServices.length} selected + ) : null} + {hasSelection && hasStopped ? ( + + ) : null} + {hasSelection && hasRunning ? ( + + ) : null} + {hasSelection ? ( + + ) : null} + {hasSelection ? ( + + ) : null} +
+ +
+ {services.length === 0 ? ( +
+

No services in this stack yet.

+
+ ) : ( + navigate(injector, `/services/${serviceId}/logs`)} + onDetails={(serviceId) => navigate(injector, `/services/${serviceId}`)} + onEdit={(serviceId) => + navigate(injector, `/services/${serviceId}?${serializeToQueryString({ edit: true })}`) + } + onSelectionChange={(selected: ServiceView[]) => setSelectedServices(selected)} + /> + )} + + + +
+

Repositories ({repos.length})

+ +
+ {repos.length === 0 ? ( +
+

No repositories in this stack yet.

+
+ ) : ( + navigate(injector, `/repositories/${repoId}`)} + /> + )} +
+ + ) + }, +}) diff --git a/frontend/src/pages/import-export/export-stack.tsx b/frontend/src/pages/import-export/export-stack.tsx new file mode 100644 index 0000000..3f1ed83 --- /dev/null +++ b/frontend/src/pages/import-export/export-stack.tsx @@ -0,0 +1,102 @@ +import { createComponent, Shade } from '@furystack/shades' +import { + Button, + cssVariableTheme, + Icon, + icons, + Loader, + NotyService, + PageContainer, + PageHeader, + Paper, +} from '@furystack/shades-common-components' +import { StacksApiClient } from '../../services/api-clients/stacks-api-client.js' + +type ExportStackProps = { + stackName: string +} + +export const ExportStack = Shade({ + shadowDomName: 'shade-export-stack', + render: ({ props, injector, useState }) => { + const [jsonOutput, setJsonOutput] = useState('json', '') + const [isLoading, setIsLoading] = useState('isLoading', true) + + if (isLoading && !jsonOutput) { + injector + .getInstance(StacksApiClient) + .call({ + method: 'GET', + action: '/stacks/:id/export', + url: { id: props.stackName }, + }) + .then(({ result }) => { + setJsonOutput(JSON.stringify(result, null, 2)) + setIsLoading(false) + }) + .catch(() => setIsLoading(false)) + } + + return ( + + history.back()} + startIcon={} + > + Back + + } + /> + + {isLoading ? ( +
+ +
+ ) : ( + +