diff --git a/package.json b/package.json index 051b5d4e..6e4c4b31 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,18 @@ "description": "Monorepo workspace for effect-template", "packageManager": "pnpm@10.28.0", "workspaces": [ + "packages/api", "packages/app", "packages/lib" ], "scripts": { "setup:pre-commit-hook": "node scripts/setup-pre-commit-hook.js", "build": "pnpm --filter ./packages/app build", + "api:build": "pnpm --filter ./packages/api build", + "api:start": "pnpm --filter ./packages/api start", + "api:dev": "pnpm --filter ./packages/api dev", + "api:test": "pnpm --filter ./packages/api test", + "api:typecheck": "pnpm --filter ./packages/api typecheck", "check": "pnpm --filter ./packages/app check && pnpm --filter ./packages/lib typecheck", "changeset": "changeset", "changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", diff --git a/packages/api/.gitignore b/packages/api/.gitignore new file mode 100644 index 00000000..8fd037b9 --- /dev/null +++ b/packages/api/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +.vitest/ diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 00000000..fdc5413d --- /dev/null +++ b/packages/api/README.md @@ -0,0 +1,73 @@ +# @effect-template/api + +Clean-slate v1 HTTP API for docker-git orchestration. + +## UI wrapper + +После запуска API открой: + +- `http://localhost:3334/` + +Это встроенная фронт-обвязка для ручного тестирования endpoint-ов (проекты, агенты, логи, SSE). + +## Run + +```bash +pnpm --filter ./packages/api build +pnpm --filter ./packages/api start +``` + +Env: + +- `DOCKER_GIT_API_PORT` (default: `3334`) +- `DOCKER_GIT_PROJECTS_ROOT` (default: `~/.docker-git`) +- `DOCKER_GIT_API_LOG_LEVEL` (default: `info`) +- `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub domain, e.g. `https://social.my-domain.tld`) +- `DOCKER_GIT_FEDERATION_ACTOR` (default: `docker-git`) + +## Endpoints + +- `GET /health` +- `POST /federation/inbox` (ForgeFed `Ticket` / `Offer(Ticket)`, ActivityPub `Accept` / `Reject`) +- `GET /federation/issues` +- `GET /federation/actor` (ActivityPub `Person`) +- `GET /federation/outbox` +- `GET /federation/followers` +- `GET /federation/following` +- `GET /federation/liked` +- `POST /federation/follows` (create ActivityPub `Follow` activity for task-feed subscription) +- `GET /federation/follows` +- `GET /projects` +- `GET /projects/:projectId` +- `POST /projects` +- `DELETE /projects/:projectId` +- `POST /projects/:projectId/up` +- `POST /projects/:projectId/down` +- `POST /projects/:projectId/recreate` +- `GET /projects/:projectId/ps` +- `GET /projects/:projectId/logs` +- `GET /projects/:projectId/events` (SSE) +- `POST /projects/:projectId/agents` +- `GET /projects/:projectId/agents` +- `GET /projects/:projectId/agents/:agentId` +- `GET /projects/:projectId/agents/:agentId/attach` +- `POST /projects/:projectId/agents/:agentId/stop` +- `GET /projects/:projectId/agents/:agentId/logs` + +## Example + +```bash +curl -s http://localhost:3334/projects +curl -s -X POST http://localhost:3334/projects//up +curl -s -N http://localhost:3334/projects//events + +curl -s http://localhost:3334/federation/actor + +curl -s -X POST http://localhost:3334/federation/follows \ + -H 'content-type: application/json' \ + -d '{"domain":"social.my-domain.tld","object":"https://social.my-domain.tld/issues/followers"}' + +curl -s -X POST http://localhost:3334/federation/inbox \ + -H 'content-type: application/json' \ + -d '{"@context":["https://www.w3.org/ns/activitystreams","https://forgefed.org/ns"],"id":"https://social.my-domain.tld/offers/42","type":"Offer","target":"https://social.my-domain.tld/issues","object":{"type":"Ticket","id":"https://social.my-domain.tld/issues/42","attributedTo":"https://origin.my-domain.tld/users/alice","summary":"Title","content":"Body"}}' +``` diff --git a/packages/api/eslint.config.mjs b/packages/api/eslint.config.mjs new file mode 100644 index 00000000..0d55b554 --- /dev/null +++ b/packages/api/eslint.config.mjs @@ -0,0 +1,29 @@ +import js from "@eslint/js" +import globals from "globals" +import tsPlugin from "@typescript-eslint/eslint-plugin" +import tsParser from "@typescript-eslint/parser" + +export default [ + { + ignores: ["dist/**"] + }, + js.configs.recommended, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + sourceType: "module" + }, + globals: { + ...globals.node + } + }, + plugins: { + "@typescript-eslint": tsPlugin + }, + rules: { + ...tsPlugin.configs.recommended.rules + } + } +] diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 00000000..eada1be2 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,38 @@ +{ + "name": "@effect-template/api", + "version": "0.1.0", + "private": true, + "description": "docker-git clean-slate v1 API", + "main": "dist/src/main.js", + "type": "module", + "scripts": { + "prebuild": "pnpm -C ../lib build", + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", + "prestart": "pnpm run build", + "start": "node dist/src/main.js", + "pretypecheck": "pnpm -C ../lib build", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "eslint .", + "pretest": "pnpm -C ../lib build", + "test": "vitest run" + }, + "dependencies": { + "@effect-template/lib": "workspace:*", + "@effect/platform": "^0.94.1", + "@effect/platform-node": "^0.104.0", + "@effect/schema": "^0.75.5", + "effect": "^3.19.14" + }, + "devDependencies": { + "@effect/vitest": "^0.27.0", + "@eslint/js": "9.39.1", + "@types/node": "^24.10.1", + "@typescript-eslint/eslint-plugin": "^8.48.1", + "@typescript-eslint/parser": "^8.48.1", + "eslint": "^9.39.1", + "globals": "^16.5.0", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts new file mode 100644 index 00000000..9b76b7b0 --- /dev/null +++ b/packages/api/src/api/contracts.ts @@ -0,0 +1,226 @@ +export type ProjectStatus = "running" | "stopped" | "unknown" + +export type AgentProvider = "codex" | "opencode" | "claude" | "custom" + +export type AgentStatus = "starting" | "running" | "stopping" | "stopped" | "exited" | "failed" + +export type ProjectSummary = { + readonly id: string + readonly displayName: string + readonly repoUrl: string + readonly repoRef: string + readonly status: ProjectStatus + readonly statusLabel: string +} + +export type ProjectDetails = ProjectSummary & { + readonly containerName: string + readonly serviceName: string + readonly sshUser: string + readonly sshPort: number + readonly targetDir: string + readonly projectDir: string + readonly sshCommand: string + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string + readonly codexHome: string +} + +export type CreateProjectRequest = { + readonly repoUrl?: string | undefined + readonly repoRef?: string | undefined + readonly targetDir?: string | undefined + readonly sshPort?: string | undefined + readonly sshUser?: string | undefined + readonly containerName?: string | undefined + readonly serviceName?: string | undefined + readonly volumeName?: string | undefined + readonly secretsRoot?: string | undefined + readonly authorizedKeysPath?: string | undefined + readonly envGlobalPath?: string | undefined + readonly envProjectPath?: string | undefined + readonly codexAuthPath?: string | undefined + readonly codexHome?: string | undefined + readonly dockerNetworkMode?: string | undefined + readonly dockerSharedNetworkName?: string | undefined + readonly enableMcpPlaywright?: boolean | undefined + readonly outDir?: string | undefined + readonly gitTokenLabel?: string | undefined + readonly codexTokenLabel?: string | undefined + readonly claudeTokenLabel?: string | undefined + readonly up?: boolean | undefined + readonly openSsh?: boolean | undefined + readonly force?: boolean | undefined + readonly forceEnv?: boolean | undefined +} + +export type AgentEnvVar = { + readonly key: string + readonly value: string +} + +export type CreateAgentRequest = { + readonly provider: AgentProvider + readonly command?: string | undefined + readonly args?: ReadonlyArray | undefined + readonly cwd?: string | undefined + readonly env?: ReadonlyArray | undefined + readonly label?: string | undefined +} + +export type AgentSession = { + readonly id: string + readonly projectId: string + readonly provider: AgentProvider + readonly label: string + readonly command: string + readonly containerName: string + readonly status: AgentStatus + readonly source: string + readonly pidFile: string + readonly hostPid: number | null + readonly startedAt: string + readonly updatedAt: string + readonly stoppedAt?: string | undefined + readonly exitCode?: number | undefined + readonly signal?: string | undefined +} + +export type AgentLogLine = { + readonly at: string + readonly stream: "stdout" | "stderr" + readonly line: string +} + +export type AgentAttachInfo = { + readonly projectId: string + readonly agentId: string + readonly containerName: string + readonly pidFile: string + readonly inspectCommand: string + readonly shellCommand: string +} + +export type ForgeFedTicket = { + readonly id: string + readonly attributedTo: string + readonly summary: string + readonly content: string + readonly mediaType?: string | undefined + readonly source?: string | undefined + readonly published?: string | undefined + readonly updated?: string | undefined + readonly url?: string | undefined +} + +export type FederationIssueStatus = "offered" | "accepted" | "rejected" + +export type FederationIssueRecord = { + readonly issueId: string + readonly offerId?: string | undefined + readonly tracker?: string | undefined + readonly status: FederationIssueStatus + readonly receivedAt: string + readonly ticket: ForgeFedTicket +} + +export type CreateFollowRequest = { + readonly actor?: string | undefined + readonly object: string + readonly domain?: string | undefined + readonly inbox?: string | undefined + readonly to?: ReadonlyArray | undefined + readonly capability?: string | undefined +} + +export type FollowStatus = "pending" | "accepted" | "rejected" + +export type ActivityPubFollowActivity = { + readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly id: string + readonly type: "Follow" + readonly actor: string + readonly object: string + readonly to?: ReadonlyArray | undefined + readonly capability?: string | undefined +} + +export type ActivityPubPerson = { + readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly type: "Person" + readonly id: string + readonly name: string + readonly preferredUsername: string + readonly summary: string + readonly inbox: string + readonly outbox: string + readonly followers: string + readonly following: string + readonly liked: string +} + +export type ActivityPubOrderedCollection = { + readonly "@context": "https://www.w3.org/ns/activitystreams" + readonly type: "OrderedCollection" + readonly id: string + readonly totalItems: number + readonly orderedItems: ReadonlyArray +} + +export type FollowSubscription = { + readonly id: string + readonly activityId: string + readonly actor: string + readonly object: string + readonly inbox?: string | undefined + readonly to: ReadonlyArray + readonly capability?: string | undefined + readonly status: FollowStatus + readonly createdAt: string + readonly updatedAt: string + readonly activity: ActivityPubFollowActivity +} + +export type FollowSubscriptionCreated = { + readonly subscription: FollowSubscription + readonly activity: ActivityPubFollowActivity +} + +export type FederationInboxResult = + | { + readonly kind: "issue.offer" + readonly issue: FederationIssueRecord + } + | { + readonly kind: "issue.ticket" + readonly issue: FederationIssueRecord + } + | { + readonly kind: "follow.accept" + readonly subscription: FollowSubscription + } + | { + readonly kind: "follow.reject" + readonly subscription: FollowSubscription + } + +export type ApiEventType = + | "snapshot" + | "project.created" + | "project.deleted" + | "project.deployment.status" + | "project.deployment.log" + | "agent.started" + | "agent.output" + | "agent.exited" + | "agent.stopped" + | "agent.error" + +export type ApiEvent = { + readonly seq: number + readonly projectId: string + readonly type: ApiEventType + readonly at: string + readonly payload: unknown +} diff --git a/packages/api/src/api/errors.ts b/packages/api/src/api/errors.ts new file mode 100644 index 00000000..9d59a550 --- /dev/null +++ b/packages/api/src/api/errors.ts @@ -0,0 +1,28 @@ +import { Data } from "effect" + +export class ApiBadRequestError extends Data.TaggedError("ApiBadRequestError")<{ + readonly message: string + readonly details?: unknown +}> {} + +export class ApiNotFoundError extends Data.TaggedError("ApiNotFoundError")<{ + readonly message: string +}> {} + +export class ApiConflictError extends Data.TaggedError("ApiConflictError")<{ + readonly message: string +}> {} + +export class ApiInternalError extends Data.TaggedError("ApiInternalError")<{ + readonly message: string + readonly cause?: unknown +}> {} + +export type ApiKnownError = + | ApiBadRequestError + | ApiNotFoundError + | ApiConflictError + | ApiInternalError + +export const describeUnknown = (error: unknown): string => + error instanceof Error ? (error.stack ?? error.message) : String(error) diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts new file mode 100644 index 00000000..5a518621 --- /dev/null +++ b/packages/api/src/api/schema.ts @@ -0,0 +1,85 @@ +import * as Schema from "effect/Schema" + +const OptionalString = Schema.optional(Schema.String) +const OptionalBoolean = Schema.optional(Schema.Boolean) + +export const CreateProjectRequestSchema = Schema.Struct({ + repoUrl: OptionalString, + repoRef: OptionalString, + targetDir: OptionalString, + sshPort: OptionalString, + sshUser: OptionalString, + containerName: OptionalString, + serviceName: OptionalString, + volumeName: OptionalString, + secretsRoot: OptionalString, + authorizedKeysPath: OptionalString, + envGlobalPath: OptionalString, + envProjectPath: OptionalString, + codexAuthPath: OptionalString, + codexHome: OptionalString, + dockerNetworkMode: OptionalString, + dockerSharedNetworkName: OptionalString, + enableMcpPlaywright: OptionalBoolean, + outDir: OptionalString, + gitTokenLabel: OptionalString, + codexTokenLabel: OptionalString, + claudeTokenLabel: OptionalString, + up: OptionalBoolean, + openSsh: OptionalBoolean, + force: OptionalBoolean, + forceEnv: OptionalBoolean +}) + +export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom") + +export const AgentEnvVarSchema = Schema.Struct({ + key: Schema.String, + value: Schema.String +}) + +export const CreateAgentRequestSchema = Schema.Struct({ + provider: AgentProviderSchema, + command: OptionalString, + args: Schema.optional(Schema.Array(Schema.String)), + cwd: OptionalString, + env: Schema.optional(Schema.Array(AgentEnvVarSchema)), + label: OptionalString +}) + +export const CreateFollowRequestSchema = Schema.Struct({ + actor: OptionalString, + object: Schema.String, + domain: OptionalString, + inbox: OptionalString, + to: Schema.optional(Schema.Array(Schema.String)), + capability: OptionalString +}) + +export const AgentSessionSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + provider: AgentProviderSchema, + label: Schema.String, + command: Schema.String, + containerName: Schema.String, + status: Schema.Literal("starting", "running", "stopping", "stopped", "exited", "failed"), + source: Schema.String, + pidFile: Schema.String, + hostPid: Schema.NullOr(Schema.Number), + startedAt: Schema.String, + updatedAt: Schema.String, + stoppedAt: OptionalString, + exitCode: Schema.optional(Schema.Number), + signal: OptionalString +}) + +export const AgentLogLineSchema = Schema.Struct({ + at: Schema.String, + stream: Schema.Literal("stdout", "stderr"), + line: Schema.String +}) + +export type CreateProjectRequestInput = Schema.Schema.Type +export type CreateAgentRequestInput = Schema.Schema.Type +export type CreateFollowRequestInput = Schema.Schema.Type diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts new file mode 100644 index 00000000..420b2fef --- /dev/null +++ b/packages/api/src/http.ts @@ -0,0 +1,452 @@ +import { Chunk, Duration, Effect, Ref } from "effect" +import * as Stream from "effect/Stream" +import type { PlatformError } from "@effect/platform/Error" +import type * as HttpBody from "@effect/platform/HttpBody" +import * as HttpRouter from "@effect/platform/HttpRouter" +import * as HttpServerRequest from "@effect/platform/HttpServerRequest" +import * as HttpServerResponse from "@effect/platform/HttpServerResponse" +import * as HttpServerError from "@effect/platform/HttpServerError" +import * as ParseResult from "effect/ParseResult" +import * as Schema from "effect/Schema" + +import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" +import { CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema } from "./api/schema.js" +import { uiHtml, uiScript, uiStyles } from "./ui.js" +import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js" +import { latestProjectCursor, listProjectEventsSince } from "./services/events.js" +import { + createFollowSubscription, + ingestFederationInbox, + listFederationIssues, + listFollowSubscriptions, + makeFederationActorDocument, + makeFederationContext, + makeFederationFollowersCollection, + makeFederationFollowingCollection, + makeFederationLikedCollection, + makeFederationOutboxCollection +} from "./services/federation.js" +import { + createProjectFromRequest, + deleteProjectById, + downProject, + getProject, + listProjects, + readProjectLogs, + readProjectPs, + recreateProject, + upProject +} from "./services/projects.js" + +const ProjectParamsSchema = Schema.Struct({ + projectId: Schema.String +}) + +const AgentParamsSchema = Schema.Struct({ + projectId: Schema.String, + agentId: Schema.String +}) + +type ApiError = + | ApiBadRequestError + | ApiNotFoundError + | ApiConflictError + | ApiInternalError + | ParseResult.ParseError + | HttpBody.HttpBodyError + | HttpServerError.RequestError + | PlatformError + +const jsonResponse = (data: unknown, status: number) => + Effect.map(HttpServerResponse.json(data), (response) => HttpServerResponse.setStatus(response, status)) + +const textResponse = (data: string, contentType: string, status = 200) => + Effect.succeed( + HttpServerResponse.setStatus( + HttpServerResponse.text(data, { contentType }), + status + ) + ) + +const parseQueryInt = (url: string, key: string, fallback: number): number => { + const parsed = Number(new URL(url, "http://localhost").searchParams.get(key) ?? "") + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback + } + return Math.floor(parsed) +} + +const errorResponse = (error: ApiError | unknown) => { + if (ParseResult.isParseError(error)) { + return jsonResponse( + { + error: { + type: "ParseError", + message: ParseResult.TreeFormatter.formatIssueSync(error.issue) + } + }, + 400 + ) + } + + if (error instanceof ApiBadRequestError) { + return jsonResponse({ error: { type: error._tag, message: error.message, details: error.details } }, 400) + } + + if (error instanceof ApiNotFoundError) { + return jsonResponse({ error: { type: error._tag, message: error.message } }, 404) + } + + if (error instanceof ApiConflictError) { + return jsonResponse({ error: { type: error._tag, message: error.message } }, 409) + } + + if (error instanceof ApiInternalError) { + return jsonResponse({ error: { type: error._tag, message: error.message } }, 500) + } + + return jsonResponse( + { + error: { + type: "InternalError", + message: describeUnknown(error) + } + }, + 500 + ) +} + +const projectParams = HttpRouter.schemaParams(ProjectParamsSchema) +const agentParams = HttpRouter.schemaParams(AgentParamsSchema) + +const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreateProjectRequestSchema) +const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema) +const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown) + +const configuredFederationPublicOrigin = + process.env["DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN"] ?? + process.env["DOCKER_GIT_API_PUBLIC_URL"] + +const configuredFederationActorUsername = + process.env["DOCKER_GIT_FEDERATION_ACTOR"] ?? "docker-git" + +const readHeader = ( + request: HttpServerRequest.HttpServerRequest, + key: string +): string | undefined => { + const value = request.headers[key.toLowerCase()] + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined +} + +const firstCommaValue = (value: string | undefined): string | undefined => { + if (value === undefined) { + return undefined + } + const first = value.split(",")[0]?.trim() + return first && first.length > 0 ? first : undefined +} + +const resolveRequestOrigin = (request: HttpServerRequest.HttpServerRequest): string => { + const forwardedHost = firstCommaValue(readHeader(request, "x-forwarded-host")) + const host = forwardedHost ?? readHeader(request, "host") + const proto = firstCommaValue(readHeader(request, "x-forwarded-proto")) ?? "http" + if (host === undefined || host.length === 0) { + return "http://localhost:3334" + } + return `${proto}://${host}` +} + +const resolveFederationContext = ( + request: HttpServerRequest.HttpServerRequest, + requestedDomain?: string | undefined +) => { + const fromBody = requestedDomain?.trim() + const publicOrigin = + fromBody && fromBody.length > 0 + ? fromBody + : configuredFederationPublicOrigin ?? resolveRequestOrigin(request) + + return makeFederationContext({ + publicOrigin, + actorUsername: configuredFederationActorUsername + }) +} + +export const makeRouter = () => { + const base = HttpRouter.empty.pipe( + HttpRouter.get("/", textResponse(uiHtml, "text/html; charset=utf-8", 200)), + HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), + HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), + HttpRouter.get("/health", jsonResponse({ ok: true }, 200)), + HttpRouter.get( + "/federation/issues", + Effect.sync(() => ({ issues: listFederationIssues() })).pipe( + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/federation/actor", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationActorDocument(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/federation/outbox", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationOutboxCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/federation/followers", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationFollowersCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/federation/following", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationFollowingCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/federation/liked", + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request)) + return yield* _(jsonResponse(makeFederationLikedCollection(context), 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/federation/follows", + Effect.gen(function*(_) { + const requestBody = yield* _(readCreateFollowRequest()) + const request = yield* _(HttpServerRequest.HttpServerRequest) + const context = yield* _(resolveFederationContext(request, requestBody.domain)) + const created = yield* _(createFollowSubscription(requestBody, context)) + return yield* _(jsonResponse(created, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/federation/follows", + Effect.sync(() => ({ follows: listFollowSubscriptions() })).pipe( + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/federation/inbox", + Effect.gen(function*(_) { + const payload = yield* _(readInboxPayload()) + const result = yield* _(ingestFederationInbox(payload)) + return yield* _(jsonResponse({ result }, 202)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + const withProjects = base.pipe( + HttpRouter.get( + "/projects", + listProjects().pipe( + Effect.flatMap((projects) => jsonResponse({ projects }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects", + Effect.gen(function*(_) { + const request = yield* _(readCreateProjectRequest()) + const project = yield* _(createProjectFromRequest(request)) + return yield* _(jsonResponse({ project }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/projects/:projectId", + projectParams.pipe( + Effect.flatMap(({ projectId }) => getProject(projectId)), + Effect.flatMap((project) => jsonResponse({ project }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.del( + "/projects/:projectId", + projectParams.pipe( + Effect.flatMap(({ projectId }) => deleteProjectById(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/:projectId/up", + projectParams.pipe( + Effect.flatMap(({ projectId }) => upProject(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/:projectId/down", + projectParams.pipe( + Effect.flatMap(({ projectId }) => downProject(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/:projectId/recreate", + projectParams.pipe( + Effect.flatMap(({ projectId }) => recreateProject(projectId)), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/projects/:projectId/ps", + projectParams.pipe( + Effect.flatMap(({ projectId }) => readProjectPs(projectId)), + Effect.flatMap((output) => jsonResponse({ output }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/projects/:projectId/logs", + projectParams.pipe( + Effect.flatMap(({ projectId }) => readProjectLogs(projectId)), + Effect.flatMap((output) => jsonResponse({ output }, 200)), + Effect.catchAll(errorResponse) + ) + ) + ) + + const withAgents = withProjects.pipe( + HttpRouter.post( + "/projects/:projectId/agents", + Effect.gen(function*(_) { + const { projectId } = yield* _(projectParams) + const project = yield* _(getProject(projectId)) + const request = yield* _(HttpServerRequest.schemaBodyJson(CreateAgentRequestSchema)) + const session = yield* _(startAgent(project, request)) + return yield* _(jsonResponse({ session }, 201)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/projects/:projectId/agents", + projectParams.pipe( + Effect.flatMap(({ projectId }) => jsonResponse({ sessions: listAgents(projectId) }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/projects/:projectId/agents/:agentId", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => getAgent(projectId, agentId)), + Effect.flatMap((session) => jsonResponse({ session }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/projects/:projectId/agents/:agentId/attach", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => getAgentAttachInfo(projectId, agentId)), + Effect.flatMap((attach) => jsonResponse({ attach }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/:projectId/agents/:agentId/stop", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => + Effect.gen(function*(_) { + const project = yield* _(getProject(projectId)) + return yield* _(stopAgent(projectId, project.projectDir, project.containerName, agentId)) + }) + ), + Effect.flatMap((session) => jsonResponse({ session }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.get( + "/projects/:projectId/agents/:agentId/logs", + agentParams.pipe( + Effect.flatMap(({ projectId, agentId }) => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const lines = parseQueryInt(request.url, "lines", 200) + const entries = yield* _(readAgentLogs(projectId, agentId, lines)) + return { entries, lines } + }) + ), + Effect.flatMap((payload) => jsonResponse(payload, 200)), + Effect.catchAll(errorResponse) + ) + ) + ) + + return withAgents.pipe( + HttpRouter.get( + "/projects/:projectId/events", + projectParams.pipe( + Effect.flatMap(({ projectId }) => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const startCursor = parseQueryInt(request.url, "cursor", 0) + const cursorRef = yield* _(Ref.make(startCursor)) + const snapshotRef = yield* _(Ref.make(false)) + const encoder = new TextEncoder() + + const encodeSse = (event: string, data: unknown, id?: number): Uint8Array => { + const idLine = id === undefined ? "" : `id: ${id}\n` + return encoder.encode(`${idLine}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + + const poll = Effect.gen(function* (_) { + const snapshotSent = yield* _(Ref.get(snapshotRef)) + + if (!snapshotSent) { + yield* _(Ref.set(snapshotRef, true)) + const cursor = latestProjectCursor(projectId) + yield* _(Ref.set(cursorRef, cursor)) + return Chunk.of( + encodeSse("snapshot", { + projectId, + cursor, + agents: listAgents(projectId) + }, cursor) + ) + } + + const currentCursor = yield* _(Ref.get(cursorRef)) + const events = listProjectEventsSince(projectId, currentCursor) + if (events.length === 0) { + yield* _(Effect.sleep(Duration.millis(500))) + return Chunk.empty() + } + + const nextCursor = events[events.length - 1]?.seq ?? currentCursor + yield* _(Ref.set(cursorRef, nextCursor)) + const encoded = events.map((event) => encodeSse(event.type, event, event.seq)) + return Chunk.fromIterable(encoded) + }) + + return HttpServerResponse.stream(Stream.repeatEffectChunk(poll), { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + "connection": "keep-alive" + } + }) + }) + ), + Effect.catchAll(errorResponse) + ) + ) + ) +} diff --git a/packages/api/src/main.ts b/packages/api/src/main.ts new file mode 100644 index 00000000..17fc7466 --- /dev/null +++ b/packages/api/src/main.ts @@ -0,0 +1,6 @@ +import { NodeContext, NodeRuntime } from "@effect/platform-node" +import { Effect } from "effect" + +import { program } from "./program.js" + +NodeRuntime.runMain(program.pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/api/src/program.ts b/packages/api/src/program.ts new file mode 100644 index 00000000..273ba0b9 --- /dev/null +++ b/packages/api/src/program.ts @@ -0,0 +1,52 @@ +import { HttpMiddleware, HttpServer, HttpServerRequest } from "@effect/platform" +import { NodeHttpServer } from "@effect/platform-node" +import { Console, Effect, Layer, Option } from "effect" +import { createServer } from "node:http" + +import { makeRouter } from "./http.js" +import { initializeAgentState } from "./services/agents.js" + +const resolvePort = (env: Record): number => { + const raw = env["DOCKER_GIT_API_PORT"] ?? env["PORT"] + const parsed = raw === undefined ? Number.NaN : Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 3334 +} + +const requestLogger = HttpMiddleware.make((httpApp) => + Effect.gen(function*(_) { + const request = yield* _(HttpServerRequest.HttpServerRequest) + const startedAt = Date.now() + const id = `${startedAt}-${Math.floor(Math.random() * 1e6)}` + const remote = Option.getOrElse(request.remoteAddress, () => "unknown") + + yield* _(Console.log(`[api req ${id}] ${request.method} ${request.url} remote=${remote}`)) + + return yield* _( + httpApp.pipe( + Effect.tap((response) => + Console.log( + `[api res ${id}] ${request.method} ${request.url} status=${response.status} ms=${Date.now() - startedAt}` + ) + ), + Effect.tapError((error) => + Console.error(`[api err ${id}] ${request.method} ${request.url} ${String(error)}`) + ) + ) + ) + }) +) + +export const program = (() => { + const port = resolvePort(process.env) + const router = makeRouter() + const app = router.pipe(HttpServer.serve(requestLogger), HttpServer.withLogAddress) + const server = createServer() + const serverLayer = NodeHttpServer.layer(() => server, { port }) + + return Effect.scoped( + Console.log(`docker-git api boot port=${port}`).pipe( + Effect.zipRight(initializeAgentState()), + Effect.zipRight(Layer.launch(Layer.provide(app, serverLayer))) + ) + ) +})() diff --git a/packages/api/src/services/agents.ts b/packages/api/src/services/agents.ts new file mode 100644 index 00000000..f1b8f0c5 --- /dev/null +++ b/packages/api/src/services/agents.ts @@ -0,0 +1,486 @@ +import { runCommandWithExitCodes } from "@effect-template/lib/shell/command-runner" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { defaultProjectsRoot } from "@effect-template/lib/usecases/path-helpers" +import { Effect } from "effect" +import { randomUUID } from "node:crypto" +import { promises as fs } from "node:fs" +import { join } from "node:path" +import { spawn, type ChildProcess } from "node:child_process" + +import type { + AgentLogLine, + AgentSession, + CreateAgentRequest, + ProjectDetails +} from "../api/contracts.js" +import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js" +import { emitProjectEvent } from "./events.js" + +type AgentRecord = { + session: AgentSession + projectDir: string + logs: Array + process: ChildProcess | null + stdoutRemainder: string + stderrRemainder: string +} + +type SnapshotFile = { + readonly sessions: ReadonlyArray +} + +const records: Map = new Map() +const projectIndex: Map> = new Map() +const maxLogLines = 5000 +let initialized = false + +const nowIso = (): string => new Date().toISOString() + +const stateFilePath = (): string => + join(defaultProjectsRoot(process.cwd()), ".orch", "state", "api-agents.json") + +const upsertProjectIndex = (projectId: string, agentId: string): void => { + const current = projectIndex.get(projectId) + if (current) { + current.add(agentId) + return + } + projectIndex.set(projectId, new Set([agentId])) +} + +const shellEscape = (value: string): string => `'${value.replaceAll("'", "'\\''")}'` + +const sourceLabel = (request: CreateAgentRequest): string => + request.label?.trim().length ? request.label.trim() : request.provider + +const pickDefaultCommand = (provider: CreateAgentRequest["provider"]): string => { + if (provider === "codex") { + return "codex" + } + if (provider === "opencode") { + return "opencode" + } + if (provider === "claude") { + return "claude" + } + return "" +} + +const buildCommand = (request: CreateAgentRequest): string => { + const direct = request.command?.trim() ?? "" + if (direct.length > 0) { + return direct + } + + const base = pickDefaultCommand(request.provider) + if (base.length === 0) { + throw new ApiBadRequestError({ message: "Custom provider requires a non-empty 'command'." }) + } + + const args = (request.args ?? []).map((arg) => shellEscape(arg)) + return args.length === 0 ? base : `${base} ${args.join(" ")}` +} + +const buildAgentScript = ( + sessionId: string, + cwd: string, + envEntries: ReadonlyArray<{ readonly key: string; readonly value: string }>, + command: string +): string => { + const pidFile = `/tmp/docker-git-agent-${sessionId}.pid` + const exports = envEntries + .map(({ key, value }) => `export ${key}=${shellEscape(value)}`) + .join("\n") + + return [ + "set -euo pipefail", + `PID_FILE=${shellEscape(pidFile)}`, + "cleanup() { rm -f \"$PID_FILE\"; }", + "trap cleanup EXIT", + "echo $$ > \"$PID_FILE\"", + `cd ${shellEscape(cwd)}`, + exports, + `exec ${command}` + ] + .filter((line) => line.trim().length > 0) + .join("\n") +} + +const trimLogs = (logs: Array): Array => + logs.length <= maxLogLines ? logs : logs.slice(logs.length - maxLogLines) + +const persistSnapshot = async (): Promise => { + const filePath = stateFilePath() + await fs.mkdir(join(filePath, ".."), { recursive: true }) + const payload: SnapshotFile = { + sessions: [...records.values()].map((record) => record.session) + } + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8") +} + +const persistSnapshotBestEffort = (): void => { + void persistSnapshot().catch(() => { + // best effort snapshot persistence + }) +} + +const updateSession = ( + record: AgentRecord, + patch: Partial +): void => { + record.session = { + ...record.session, + ...patch, + updatedAt: nowIso() + } + records.set(record.session.id, record) + persistSnapshotBestEffort() +} + +const appendLog = ( + record: AgentRecord, + stream: AgentLogLine["stream"], + line: string +): void => { + const entry: AgentLogLine = { + at: nowIso(), + stream, + line + } + record.logs = trimLogs([...record.logs, entry]) + emitProjectEvent(record.session.projectId, "agent.output", { + agentId: record.session.id, + stream, + line, + at: entry.at + }) +} + +const flushRemainder = (record: AgentRecord, stream: AgentLogLine["stream"]): void => { + const remainder = stream === "stdout" ? record.stdoutRemainder : record.stderrRemainder + if (remainder.length === 0) { + return + } + appendLog(record, stream, remainder) + if (stream === "stdout") { + record.stdoutRemainder = "" + } else { + record.stderrRemainder = "" + } +} + +const consumeChunk = ( + record: AgentRecord, + stream: AgentLogLine["stream"], + chunk: Buffer +): void => { + const incoming = chunk.toString("utf8") + const withRemainder = (stream === "stdout" ? record.stdoutRemainder : record.stderrRemainder) + incoming + const lines = withRemainder.split(/\r?\n/u) + const tail = lines.pop() ?? "" + + for (const line of lines) { + if (line.length > 0) { + appendLog(record, stream, line) + } + } + + if (stream === "stdout") { + record.stdoutRemainder = tail + } else { + record.stderrRemainder = tail + } +} + +const getProjectAgentIds = (projectId: string): ReadonlyArray => { + const ids = projectIndex.get(projectId) + return ids ? [...ids] : [] +} + +const getRecordOrFail = ( + projectId: string, + agentId: string +): Effect.Effect => + Effect.gen(function*(_) { + const record = records.get(agentId) + if (!record || record.session.projectId !== projectId) { + return yield* _( + Effect.fail( + new ApiNotFoundError({ message: `Agent not found: ${agentId} in project ${projectId}` }) + ) + ) + } + return record + }) + +const endedStatuses: ReadonlySet = new Set(["stopped", "exited", "failed"]) + +const killAgentScript = (sessionId: string): string => { + const pidFile = `/tmp/docker-git-agent-${sessionId}.pid` + return [ + "set -eu", + `PID_FILE=${shellEscape(pidFile)}`, + "if [ -f \"$PID_FILE\" ]; then", + " PID=$(cat \"$PID_FILE\" 2>/dev/null || true)", + " if [ -n \"$PID\" ]; then", + " kill -TERM \"$PID\" 2>/dev/null || true", + " sleep 2", + " if kill -0 \"$PID\" 2>/dev/null; then kill -KILL \"$PID\" 2>/dev/null || true; fi", + " fi", + "fi" + ].join("\n") +} + +const hydrateFromSnapshot = async (): Promise => { + const filePath = stateFilePath() + const exists = await fs.stat(filePath).then(() => true).catch(() => false) + if (!exists) { + return + } + + const raw = await fs.readFile(filePath, "utf8") + const parsed = JSON.parse(raw) as SnapshotFile + for (const session of parsed.sessions ?? []) { + const restored: AgentSession = { + ...session, + status: endedStatuses.has(session.status) ? session.status : "exited", + hostPid: null, + stoppedAt: session.stoppedAt ?? nowIso(), + updatedAt: nowIso() + } + + const record: AgentRecord = { + session: restored, + projectDir: "", + logs: [], + process: null, + stdoutRemainder: "", + stderrRemainder: "" + } + + records.set(restored.id, record) + upsertProjectIndex(restored.projectId, restored.id) + } +} + +export const initializeAgentState = () => + Effect.tryPromise({ + try: async () => { + if (initialized) { + return + } + await hydrateFromSnapshot() + initialized = true + }, + catch: (error) => new Error(String(error)) + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.asVoid + ) + +// CHANGE: start an agent process inside a project container and register it for API control. +// WHY: issue #84 requires non-CLI lifecycle control for Codex/OpenCode/Claude runs. +// QUOTE(ТЗ): "Запускать агентов" +// REF: issue-84-agent-start +// SOURCE: n/a +// FORMAT THEOREM: forall req: valid(req) -> exists(session(req)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: agent ids are unique UUIDs and state snapshots are persisted best-effort +// COMPLEXITY: O(1) +export const startAgent = ( + project: ProjectDetails, + request: CreateAgentRequest +)=> + Effect.try({ + try: () => { + const command = buildCommand(request) + const sessionId = randomUUID() + const pidFile = `/tmp/docker-git-agent-${sessionId}.pid` + const label = sourceLabel(request) + const startedAt = nowIso() + const workingDir = request.cwd?.trim() || project.targetDir + + const session: AgentSession = { + id: sessionId, + projectId: project.id, + provider: request.provider, + label, + command, + containerName: project.containerName, + status: "starting", + source: `provider:${request.provider}`, + pidFile, + hostPid: null, + startedAt, + updatedAt: startedAt + } + + const record: AgentRecord = { + session, + projectDir: project.projectDir, + logs: [], + process: null, + stdoutRemainder: "", + stderrRemainder: "" + } + + records.set(sessionId, record) + upsertProjectIndex(project.id, sessionId) + + const script = buildAgentScript(sessionId, workingDir, request.env ?? [], command) + const child = spawn( + "docker", + ["exec", "-i", project.containerName, "bash", "-lc", script], + { + cwd: project.projectDir, + env: process.env, + stdio: ["ignore", "pipe", "pipe"] + } + ) + + record.process = child + updateSession(record, { + status: "running", + hostPid: child.pid ?? null + }) + + emitProjectEvent(project.id, "agent.started", { + agentId: sessionId, + provider: request.provider, + label, + command + }) + + child.stdout.on("data", (chunk: Buffer) => { + consumeChunk(record, "stdout", chunk) + }) + + child.stderr.on("data", (chunk: Buffer) => { + consumeChunk(record, "stderr", chunk) + }) + + child.on("error", (error) => { + updateSession(record, { + status: "failed", + stoppedAt: nowIso() + }) + emitProjectEvent(project.id, "agent.error", { + agentId: sessionId, + message: error.message + }) + }) + + child.on("close", (exitCode, signal) => { + flushRemainder(record, "stdout") + flushRemainder(record, "stderr") + + const expectedStop = record.session.status === "stopping" || record.session.status === "stopped" + const nextStatus: AgentSession["status"] = expectedStop + ? "stopped" + : (exitCode === 0 ? "exited" : "failed") + + updateSession(record, { + status: nextStatus, + hostPid: null, + stoppedAt: nowIso(), + ...(exitCode === null ? {} : { exitCode }), + ...(signal === null ? {} : { signal }) + }) + + emitProjectEvent(project.id, expectedStop ? "agent.stopped" : "agent.exited", { + agentId: sessionId, + exitCode, + signal, + status: nextStatus + }) + }) + + persistSnapshotBestEffort() + return record.session + }, + catch: (error) => { + if (error instanceof ApiBadRequestError) { + return error + } + if (error instanceof ApiConflictError) { + return error + } + return new ApiConflictError({ message: `Failed to start agent: ${String(error)}` }) + } + }) + +export const listAgents = (projectId: string): ReadonlyArray => + getProjectAgentIds(projectId) + .map((id) => records.get(id)) + .filter((record): record is AgentRecord => Boolean(record)) + .map((record) => record.session) + +export const getAgent = ( + projectId: string, + agentId: string +) => + getRecordOrFail(projectId, agentId).pipe(Effect.map((record) => record.session)) + +export const stopAgent = ( + projectId: string, + projectDir: string, + containerName: string, + agentId: string +)=> + Effect.gen(function*(_) { + const record = yield* _(getRecordOrFail(projectId, agentId)) + + if (endedStatuses.has(record.session.status)) { + return record.session + } + + updateSession(record, { status: "stopping" }) + + const command = killAgentScript(agentId) + yield* _( + runCommandWithExitCodes( + { + cwd: projectDir, + command: "docker", + args: ["exec", containerName, "bash", "-lc", command] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker exec kill-agent", exitCode }) + ).pipe(Effect.catchAll(() => Effect.void)) + ) + + if (record.process && !record.process.killed) { + record.process.kill("SIGTERM") + } + + emitProjectEvent(projectId, "agent.stopped", { agentId, message: "Stop signal sent" }) + return record.session + }) + +export const readAgentLogs = ( + projectId: string, + agentId: string, + lines: number +)=> + getRecordOrFail(projectId, agentId).pipe( + Effect.map((record) => { + const safe = Number.isFinite(lines) && lines > 0 ? Math.min(Math.floor(lines), maxLogLines) : 200 + return record.logs.slice(record.logs.length - safe) + }) + ) + +export const getAgentAttachInfo = ( + projectId: string, + agentId: string +)=> + getRecordOrFail(projectId, agentId).pipe( + Effect.map((record) => ({ + projectId, + agentId, + containerName: record.session.containerName, + pidFile: record.session.pidFile, + inspectCommand: `docker exec ${record.session.containerName} bash -lc 'cat ${record.session.pidFile}'`, + shellCommand: `docker exec -it ${record.session.containerName} bash` + })) + ) diff --git a/packages/api/src/services/events.ts b/packages/api/src/services/events.ts new file mode 100644 index 00000000..2c7cdfdf --- /dev/null +++ b/packages/api/src/services/events.ts @@ -0,0 +1,75 @@ +import type { ApiEvent, ApiEventType } from "../api/contracts.js" + +type ProjectEventsState = { + nextSeq: number + events: Array +} + +const maxEventsPerProject = 4000 +const state: Map = new Map() + +const nowIso = (): string => new Date().toISOString() + +const getProjectState = (projectId: string): ProjectEventsState => { + const existing = state.get(projectId) + if (existing) { + return existing + } + const created: ProjectEventsState = { + nextSeq: 1, + events: [] + } + state.set(projectId, created) + return created +} + +const trimEvents = (events: Array): Array => + events.length <= maxEventsPerProject + ? events + : events.slice(events.length - maxEventsPerProject) + +// CHANGE: append a project-scoped API event for SSE consumers. +// WHY: keep realtime streams deterministic across deployment and agent lifecycles. +// QUOTE(ТЗ): "Мне надо иметь возможность управлять полностью проектом с помощью API" +// REF: issue-84-events +// SOURCE: n/a +// FORMAT THEOREM: forall p,e: emit(p,e) -> exists(event in stream(p), event.type=e) +// PURITY: SHELL +// EFFECT: n/a +// INVARIANT: sequence numbers are strictly monotonic per project +// COMPLEXITY: O(1) +export const emitProjectEvent = ( + projectId: string, + type: ApiEventType, + payload: unknown +): ApiEvent => { + const project = getProjectState(projectId) + const event: ApiEvent = { + seq: project.nextSeq, + projectId, + type, + at: nowIso(), + payload + } + project.nextSeq += 1 + project.events = trimEvents([...project.events, event]) + return event +} + +export const listProjectEventsSince = ( + projectId: string, + cursor: number +): ReadonlyArray => { + const project = getProjectState(projectId) + return project.events.filter((event) => event.seq > cursor) +} + +export const latestProjectCursor = (projectId: string): number => { + const project = getProjectState(projectId) + const last = project.events[project.events.length - 1] + return last ? last.seq : 0 +} + +export const clearProjectEvents = (projectId: string): void => { + state.delete(projectId) +} diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts new file mode 100644 index 00000000..b07f6a8d --- /dev/null +++ b/packages/api/src/services/federation.ts @@ -0,0 +1,611 @@ +import { Effect } from "effect" +import { randomUUID } from "node:crypto" + +import type { + ActivityPubFollowActivity, + ActivityPubOrderedCollection, + ActivityPubPerson, + CreateFollowRequest, + FederationInboxResult, + FederationIssueRecord, + FollowStatus, + FollowSubscription, + FollowSubscriptionCreated, + ForgeFedTicket +} from "../api/contracts.js" +import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js" + +type JsonRecord = { readonly [key: string]: unknown } + +export type FederationContextInput = { + readonly publicOrigin: string + readonly actorUsername?: string | undefined +} + +export type FederationContext = { + readonly publicOrigin: string + readonly actorUsername: string + readonly actorId: string + readonly inbox: string + readonly outbox: string + readonly followers: string + readonly following: string + readonly liked: string + readonly followsActivityPrefix: string +} + +const defaultActorUsername = "docker-git" + +const issueStore: Map = new Map() +const followStore: Map = new Map() +const followByActivityId: Map = new Map() +const followByActorObject: Map = new Map() + +const nowIso = (): string => new Date().toISOString() + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value) + +const asRecord = (value: unknown): JsonRecord | null => + isRecord(value) ? value : null + +const asNonEmptyString = (value: unknown): string | null => + typeof value === "string" && value.trim().length > 0 ? value.trim() : null + +const readOptionalString = (record: JsonRecord, key: string): string | undefined => + asNonEmptyString(record[key]) ?? undefined + +const readRequiredString = ( + record: JsonRecord, + key: string, + label: string +): Effect.Effect => { + const value = asNonEmptyString(record[key]) + return value !== null + ? Effect.succeed(value) + : Effect.fail( + new ApiBadRequestError({ + message: `${label} must include a non-empty "${key}" field.` + }) + ) +} + +const readTypeTags = (record: JsonRecord): ReadonlyArray => { + const raw = record["type"] + if (typeof raw === "string") { + const value = raw.trim() + return value.length > 0 ? [value] : [] + } + if (Array.isArray(raw)) { + return raw + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter((item) => item.length > 0) + } + return [] +} + +const hasType = (record: JsonRecord, expected: string): boolean => + readTypeTags(record).includes(expected) + +const readObjectRecord = ( + payload: JsonRecord, + key: string, + label: string +): Effect.Effect => { + const objectRecord = asRecord(payload[key]) + return objectRecord !== null + ? Effect.succeed(objectRecord) + : Effect.fail( + new ApiBadRequestError({ + message: `${label} must include an object "${key}" payload.` + }) + ) +} + +const parseTicket = ( + payload: JsonRecord +): Effect.Effect => + Effect.gen(function*(_) { + if (!hasType(payload, "Ticket")) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "ForgeFed ticket payload must include type=\"Ticket\"." + }) + ) + ) + } + + const attributedTo = yield* _(readRequiredString(payload, "attributedTo", "ForgeFed ticket")) + const summary = yield* _(readRequiredString(payload, "summary", "ForgeFed ticket")) + const content = yield* _(readRequiredString(payload, "content", "ForgeFed ticket")) + const id = readOptionalString(payload, "id") ?? `urn:docker-git:forgefed:ticket:${randomUUID()}` + + return { + id, + attributedTo, + summary, + content, + mediaType: readOptionalString(payload, "mediaType"), + source: readOptionalString(payload, "source"), + published: readOptionalString(payload, "published"), + updated: readOptionalString(payload, "updated"), + url: readOptionalString(payload, "url") + } + }) + +const upsertIssue = (issue: FederationIssueRecord): FederationIssueRecord => { + issueStore.set(issue.issueId, issue) + return issue +} + +const followKey = (actor: string, object: string): string => `${actor}\u0000${object}` + +const cleanToRecipients = ( + raw: ReadonlyArray | undefined +): ReadonlyArray => + (raw ?? []) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + +const looksLikeAbsoluteUrl = (value: string): boolean => + /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) + +const normalizeOrigin = ( + raw: string +): Effect.Effect => + Effect.try({ + try: () => { + const trimmed = raw.trim() + if (trimmed.length === 0) { + throw new Error("Public federation domain must be non-empty.") + } + const candidate = looksLikeAbsoluteUrl(trimmed) ? trimmed : `https://${trimmed}` + const parsed = new URL(candidate) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Public federation domain must use http:// or https://.") + } + return `${parsed.protocol}//${parsed.host}` + }, + catch: (cause) => + new ApiBadRequestError({ + message: cause instanceof Error ? cause.message : String(cause) + }) + }) + +const normalizeActorUsername = ( + raw: string | undefined +): Effect.Effect => + Effect.gen(function*(_) { + const value = raw?.trim() ?? defaultActorUsername + const username = value.length === 0 ? defaultActorUsername : value + if (/[\s/]/.test(username)) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Federation actor username must not include spaces or slashes." + }) + ) + ) + } + return username + }) + +const normalizeHttpUrl = ( + raw: string, + context: FederationContext, + label: string +): Effect.Effect => + Effect.gen(function*(_) { + const value = raw.trim() + if (value.length === 0) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: `${label} must be non-empty.` + }) + ) + ) + } + + if (value.startsWith("/")) { + return `${context.publicOrigin}${value}` + } + + const candidate = looksLikeAbsoluteUrl(value) + ? value + : value.includes(".") + ? `https://${value}` + : null + + if (candidate === null) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: `${label} must be an absolute URL or "/path" relative to the configured domain.` + }) + ) + ) + } + + return yield* _( + Effect.try({ + try: () => { + const parsed = new URL(candidate) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`${label} must use http:// or https://.`) + } + + if (parsed.hostname.endsWith(".example")) { + const replacement = new URL(context.publicOrigin) + parsed.protocol = replacement.protocol + parsed.host = replacement.host + } + + return parsed.toString() + }, + catch: (cause) => + new ApiBadRequestError({ + message: cause instanceof Error ? cause.message : String(cause) + }) + }) + ) + }) + +export const makeFederationContext = ( + input: FederationContextInput +): Effect.Effect => + Effect.gen(function*(_) { + const publicOrigin = yield* _(normalizeOrigin(input.publicOrigin)) + const actorUsername = yield* _(normalizeActorUsername(input.actorUsername)) + + return { + publicOrigin, + actorUsername, + actorId: `${publicOrigin}/federation/actor`, + inbox: `${publicOrigin}/federation/inbox`, + outbox: `${publicOrigin}/federation/outbox`, + followers: `${publicOrigin}/federation/followers`, + following: `${publicOrigin}/federation/following`, + liked: `${publicOrigin}/federation/liked`, + followsActivityPrefix: `${publicOrigin}/federation/activities/follows` + } + }) + +export const makeFederationActorDocument = ( + context: FederationContext +): ActivityPubPerson => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Person", + id: context.actorId, + name: "docker-git task feed", + preferredUsername: context.actorUsername, + summary: "docker-git ActivityPub actor for task and issue stream subscriptions.", + inbox: context.inbox, + outbox: context.outbox, + followers: context.followers, + following: context.following, + liked: context.liked +}) + +export const makeFederationOutboxCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => { + const orderedItems = listFollowSubscriptions().map((subscription) => subscription.activity) + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.outbox, + totalItems: orderedItems.length, + orderedItems + } +} + +export const makeFederationFollowersCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.followers, + totalItems: 0, + orderedItems: [] +}) + +export const makeFederationFollowingCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => { + const orderedItems = listFollowSubscriptions() + .filter((subscription) => subscription.status === "accepted") + .map((subscription) => subscription.object) + + return { + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.following, + totalItems: orderedItems.length, + orderedItems + } +} + +export const makeFederationLikedCollection = ( + context: FederationContext +): ActivityPubOrderedCollection => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "OrderedCollection", + id: context.liked, + totalItems: 0, + orderedItems: [] +}) + +const lookupFollowByReference = ( + reference: string +): Effect.Effect => { + const byActivity = followByActivityId.get(reference) + if (byActivity) { + const stored = followStore.get(byActivity) + if (stored) { + return Effect.succeed(stored) + } + } + + const direct = followStore.get(reference) + if (direct) { + return Effect.succeed(direct) + } + + return Effect.fail( + new ApiNotFoundError({ + message: `Follow subscription not found for reference: ${reference}` + }) + ) +} + +const updateFollowStatus = ( + subscription: FollowSubscription, + status: FollowStatus +): FollowSubscription => { + const updated: FollowSubscription = { + ...subscription, + status, + updatedAt: nowIso() + } + followStore.set(updated.id, updated) + followByActivityId.set(updated.activityId, updated.id) + followByActorObject.set(followKey(updated.actor, updated.object), updated.id) + return updated +} + +const resolveFollowFromInbox = ( + payload: JsonRecord +): Effect.Effect => + Effect.gen(function*(_) { + const objectValue = payload["object"] + + if (typeof objectValue === "string" && objectValue.trim().length > 0) { + return yield* _(lookupFollowByReference(objectValue.trim())) + } + + const objectRecord = asRecord(objectValue) + if (objectRecord === null) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Accept/Reject payload must include object reference as string or Follow object." + }) + ) + ) + } + + const explicitId = readOptionalString(objectRecord, "id") + if (explicitId !== undefined) { + return yield* _(lookupFollowByReference(explicitId)) + } + + if (!hasType(objectRecord, "Follow")) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Accept/Reject payload object must include type=\"Follow\" when no id is provided." + }) + ) + ) + } + + const actor = yield* _(readRequiredString(objectRecord, "actor", "Follow object reference")) + const object = yield* _(readRequiredString(objectRecord, "object", "Follow object reference")) + const indexed = followByActorObject.get(followKey(actor, object)) + if (!indexed) { + return yield* _( + Effect.fail( + new ApiNotFoundError({ + message: `Follow subscription not found for actor=${actor} object=${object}` + }) + ) + ) + } + return yield* _(lookupFollowByReference(indexed)) + }) + +const ingestOfferTicket = ( + payload: JsonRecord +): Effect.Effect => + Effect.gen(function*(_) { + const objectPayload = yield* _(readObjectRecord(payload, "object", "ForgeFed offer")) + if (!hasType(objectPayload, "Ticket")) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "ForgeFed offer currently supports object.type=\"Ticket\" only." + }) + ) + ) + } + + const ticket = yield* _(parseTicket(objectPayload)) + const issueId = ticket.id + const issue = upsertIssue({ + issueId, + offerId: readOptionalString(payload, "id"), + tracker: readOptionalString(payload, "target"), + status: "offered", + receivedAt: nowIso(), + ticket + }) + return issue + }) + +const ingestDirectTicket = ( + payload: JsonRecord +): Effect.Effect => + Effect.map(parseTicket(payload), (ticket) => + upsertIssue({ + issueId: ticket.id, + status: "accepted", + receivedAt: nowIso(), + ticket + })) + +// CHANGE: support ForgeFed issue inputs and ActivityPub inbox transitions in API mode. +// WHY: Konrad requested ForgeFed Issue intake and Follow workflow support in PR discussion. +// QUOTE(ТЗ): "А сможешь на вход поддержать ... #issues" + "добавить поддержку follow" +// REF: pr-88-konrad-request +// SOURCE: n/a +// FORMAT THEOREM: ∀m: validInbox(m) → handled(m) ∈ {issue.offer, issue.ticket, follow.accept, follow.reject} +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: state transitions are deterministic for identical references +// COMPLEXITY: O(1) +export const ingestFederationInbox = ( + payload: unknown +): Effect.Effect => + Effect.gen(function*(_) { + const record = asRecord(payload) + if (record === null) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Inbox payload must be a JSON object." + }) + ) + ) + } + + if (hasType(record, "Offer")) { + const issue = yield* _(ingestOfferTicket(record)) + return { kind: "issue.offer", issue } + } + + if (hasType(record, "Ticket")) { + const issue = yield* _(ingestDirectTicket(record)) + return { kind: "issue.ticket", issue } + } + + if (hasType(record, "Accept") || hasType(record, "Reject")) { + const subscription = yield* _(resolveFollowFromInbox(record)) + const status: FollowStatus = hasType(record, "Accept") ? "accepted" : "rejected" + const updated = updateFollowStatus(subscription, status) + return status === "accepted" + ? { kind: "follow.accept", subscription: updated } + : { kind: "follow.reject", subscription: updated } + } + + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Unsupported inbox payload type. Expected Offer(Ticket), Ticket, Accept, or Reject." + }) + ) + ) + }) + +// CHANGE: build outgoing ActivityPub Follow subscriptions for task feeds. +// WHY: requested to subscribe to issue/task distribution via ActivityPub Follow. +// QUOTE(ТЗ): "добавить поддержку follow, чтобы можно было подписатся на отдачу задач" +// REF: pr-88-konrad-request +// SOURCE: n/a +// FORMAT THEOREM: ∀r: valid(r) → ∃s: s.status = pending ∧ s.object = r.object +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: non-rejected actor/object pairs are unique +// COMPLEXITY: O(1) +export const createFollowSubscription = ( + request: CreateFollowRequest, + context: FederationContext +): Effect.Effect => + Effect.gen(function*(_) { + const actor = request.actor?.trim() + ? yield* _(normalizeHttpUrl(request.actor, context, "Follow actor")) + : context.actorId + + const object = yield* _(normalizeHttpUrl(request.object, context, "Follow object")) + + const key = followKey(actor, object) + const existingId = followByActorObject.get(key) + if (existingId) { + const existing = followStore.get(existingId) + if (existing && existing.status !== "rejected") { + return yield* _( + Effect.fail( + new ApiConflictError({ + message: `Follow subscription already exists for actor=${actor} object=${object}.` + }) + ) + ) + } + } + + const to = cleanToRecipients(request.to) + const capability = request.capability?.trim() + const inbox = request.inbox?.trim() + const normalizedInbox = inbox && inbox.length > 0 + ? yield* _(normalizeHttpUrl(inbox, context, "Follow inbox")) + : undefined + + const id = randomUUID() + const activityId = `${context.followsActivityPrefix}/${id}` + const createdAt = nowIso() + + const activity: ActivityPubFollowActivity = { + "@context": "https://www.w3.org/ns/activitystreams", + id: activityId, + type: "Follow", + actor, + object, + ...(to.length === 0 ? {} : { to }), + ...(capability && capability.length > 0 ? { capability } : {}) + } + + const subscription: FollowSubscription = { + id, + activityId, + actor, + object, + inbox: normalizedInbox, + to, + capability: capability && capability.length > 0 ? capability : undefined, + status: "pending", + createdAt, + updatedAt: createdAt, + activity + } + + followStore.set(id, subscription) + followByActivityId.set(activityId, id) + followByActorObject.set(key, id) + + return { subscription, activity } + }) + +export const listFederationIssues = (): ReadonlyArray => + [...issueStore.values()].sort((left, right) => right.receivedAt.localeCompare(left.receivedAt)) + +export const listFollowSubscriptions = (): ReadonlyArray => + [...followStore.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + +export const clearFederationState = (): void => { + issueStore.clear() + followStore.clear() + followByActivityId.clear() + followByActorObject.clear() +} diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts new file mode 100644 index 00000000..11568e73 --- /dev/null +++ b/packages/api/src/services/projects.ts @@ -0,0 +1,338 @@ +import { buildCreateCommand, createProject, formatParseError, listProjectItems, readProjectConfig } from "@effect-template/lib" +import { runCommandCapture } from "@effect-template/lib/shell/command-runner" +import { CommandFailedError } from "@effect-template/lib/shell/errors" +import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects" +import type { RawOptions } from "@effect-template/lib/core/command-options" +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { Effect, Either } from "effect" + +import type { CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js" +import { ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" +import { emitProjectEvent } from "./events.js" + +const readComposePsFormatted = (cwd: string) => + runCommandCapture( + { + cwd, + command: "docker", + args: [ + "compose", + "--ansi", + "never", + "ps", + "--format", + "{{.Name}}\t{{.Status}}\t{{.Ports}}\t{{.Image}}" + ] + }, + [0], + (exitCode) => new CommandFailedError({ command: "docker compose ps", exitCode }) + ) + +const runComposeCapture = ( + projectId: string, + cwd: string, + args: ReadonlyArray, + okExitCodes: ReadonlyArray = [0] +) => + runCommandCapture( + { + cwd, + command: "docker", + args: ["compose", "--ansi", "never", ...args] + }, + okExitCodes, + (exitCode) => new CommandFailedError({ command: `docker compose ${args.join(" ")}`, exitCode }) + ).pipe( + Effect.tap((output) => + Effect.sync(() => { + for (const line of output.split(/\r?\n/u)) { + const trimmed = line.trimEnd() + if (trimmed.length > 0) { + emitProjectEvent(projectId, "project.deployment.log", { + line: trimmed, + command: `docker compose ${args.join(" ")}` + }) + } + } + }) + ) + ) + +const toProjectStatus = (raw: string): ProjectStatus => { + const normalized = raw.toLowerCase() + if (normalized.includes("up") || normalized.includes("running")) { + return "running" + } + if (normalized.includes("exited") || normalized.includes("stopped") || raw.trim().length === 0) { + return "stopped" + } + return "unknown" +} + +const statusLabelFromPs = (raw: string): string => { + const lines = raw + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + if (lines.length === 0) { + return "stopped" + } + const statuses = lines + .map((line) => { + const parts = line.split("\t") + return parts[1]?.trim() ?? "unknown" + }) + .filter((value) => value.length > 0) + return statuses.length > 0 ? statuses.join(", ") : "unknown" +} + +const withProjectRuntime = (project: ProjectItem) => + readComposePsFormatted(project.projectDir).pipe( + Effect.catchAll(() => Effect.succeed("")), + Effect.map((rawStatus) => ({ + id: project.projectDir, + displayName: project.displayName, + repoUrl: project.repoUrl, + repoRef: project.repoRef, + status: toProjectStatus(rawStatus), + statusLabel: statusLabelFromPs(rawStatus) + })) + ) + +const toProjectDetails = ( + project: ProjectItem, + summary: ProjectSummary +): ProjectDetails => ({ + ...summary, + containerName: project.containerName, + serviceName: project.serviceName, + sshUser: project.sshUser, + sshPort: project.sshPort, + targetDir: project.targetDir, + projectDir: project.projectDir, + sshCommand: project.sshCommand, + envGlobalPath: project.envGlobalPath, + envProjectPath: project.envProjectPath, + codexAuthPath: project.codexAuthPath, + codexHome: project.codexHome +}) + +const findProjectById = (projectId: string) => + listProjectItems.pipe( + Effect.flatMap((projects) => { + const project = projects.find((item) => item.projectDir === projectId) + return project + ? Effect.succeed(project) + : Effect.fail(new ApiNotFoundError({ message: `Project not found: ${projectId}` })) + }) + ) + +const resolveCreatedProject = ( + containerName: string, + repoUrl: string, + repoRef: string +) => + listProjectItems.pipe( + Effect.flatMap((items) => { + const exact = items.find((item) => + item.containerName === containerName && item.repoUrl === repoUrl && item.repoRef === repoRef) + if (exact) { + return Effect.succeed(exact) + } + const fallback = items.find((item) => item.containerName === containerName) + return fallback + ? Effect.succeed(fallback) + : Effect.fail( + new ApiInternalError({ message: "Project was created but could not be reloaded from index." }) + ) + }) + ) + +export const listProjects = () => + listProjectItems.pipe( + Effect.flatMap((projects) => Effect.forEach(projects, withProjectRuntime, { concurrency: "unbounded" })), + Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) + ) + +export const getProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + const summary = yield* _(withProjectRuntime(project)) + return toProjectDetails(project, summary) + }) + +// CHANGE: create a docker-git project exclusively through typed API input. +// WHY: issue #84 requires end-to-end project lifecycle without CLI interaction. +// QUOTE(ТЗ): "Мне надо иметь возможность управлять полностью проектом с помощью API" +// REF: issue-84-project-create +// SOURCE: n/a +// FORMAT THEOREM: forall req: valid(req) -> exists(project(req)) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: openSsh is always disabled in API mode +// COMPLEXITY: O(n) where n = number of projects in index scan +export const createProjectFromRequest = ( + request: CreateProjectRequest +) => + Effect.gen(function*(_) { + const raw: RawOptions = { + ...(request.repoUrl === undefined ? {} : { repoUrl: request.repoUrl }), + ...(request.repoRef === undefined ? {} : { repoRef: request.repoRef }), + ...(request.targetDir === undefined ? {} : { targetDir: request.targetDir }), + ...(request.sshPort === undefined ? {} : { sshPort: request.sshPort }), + ...(request.sshUser === undefined ? {} : { sshUser: request.sshUser }), + ...(request.containerName === undefined ? {} : { containerName: request.containerName }), + ...(request.serviceName === undefined ? {} : { serviceName: request.serviceName }), + ...(request.volumeName === undefined ? {} : { volumeName: request.volumeName }), + ...(request.secretsRoot === undefined ? {} : { secretsRoot: request.secretsRoot }), + ...(request.authorizedKeysPath === undefined ? {} : { authorizedKeysPath: request.authorizedKeysPath }), + ...(request.envGlobalPath === undefined ? {} : { envGlobalPath: request.envGlobalPath }), + ...(request.envProjectPath === undefined ? {} : { envProjectPath: request.envProjectPath }), + ...(request.codexAuthPath === undefined ? {} : { codexAuthPath: request.codexAuthPath }), + ...(request.codexHome === undefined ? {} : { codexHome: request.codexHome }), + ...(request.dockerNetworkMode === undefined ? {} : { dockerNetworkMode: request.dockerNetworkMode }), + ...(request.dockerSharedNetworkName === undefined ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }), + ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), + ...(request.outDir === undefined ? {} : { outDir: request.outDir }), + ...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }), + ...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }), + ...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }), + ...(request.up === undefined ? {} : { up: request.up }), + ...(request.openSsh === undefined ? {} : { openSsh: request.openSsh }), + ...(request.force === undefined ? {} : { force: request.force }), + ...(request.forceEnv === undefined ? {} : { forceEnv: request.forceEnv }) + } + + const parsed = buildCreateCommand(raw) + if (Either.isLeft(parsed)) { + return yield* _( + Effect.fail( + new ApiBadRequestError({ + message: "Invalid create payload.", + details: formatParseError(parsed.left) + }) + ) + ) + } + + const command = { + ...parsed.right, + openSsh: false + } + + yield* _( + Effect.sync(() => { + emitProjectEvent(command.outDir, "project.deployment.status", { + phase: "create", + message: "Project creation started" + }) + }) + ) + + yield* _(createProject(command)) + + const project = yield* _( + resolveCreatedProject( + command.config.containerName, + command.config.repoUrl, + command.config.repoRef + ) + ) + const summary = yield* _(withProjectRuntime(project)) + + yield* _( + Effect.sync(() => { + emitProjectEvent(project.projectDir, "project.created", { + projectId: project.projectDir, + containerName: project.containerName + }) + }) + ) + + return toProjectDetails(project, summary) + }) + +export const deleteProjectById = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(deleteDockerGitProject(project)) + yield* _( + Effect.sync(() => { + emitProjectEvent(projectId, "project.deleted", { projectId }) + }) + ) + }) + +const markDeployment = (projectId: string, phase: string, message: string) => + Effect.sync(() => { + emitProjectEvent(projectId, "project.deployment.status", { phase, message }) + }) + +export const upProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(markDeployment(projectId, "build", "docker compose up -d --build")) + yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(markDeployment(projectId, "running", "Container running")) + }) + +export const downProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(markDeployment(projectId, "down", "docker compose down")) + yield* _(runComposeCapture(projectId, project.projectDir, ["down"], [0, 1])) + yield* _(markDeployment(projectId, "idle", "Container stopped")) + }) + +export const recreateProject = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + const config = yield* _(readProjectConfig(project.projectDir)) + + yield* _(markDeployment(projectId, "recreate", "Recreate started")) + + yield* _( + createProject({ + _tag: "Create", + config: config.template, + outDir: project.projectDir, + runUp: false, + openSsh: false, + force: true, + forceEnv: false, + waitForClone: false + }) + ) + + yield* _(runComposeCapture(projectId, project.projectDir, ["down"], [0, 1])) + yield* _(runComposeCapture(projectId, project.projectDir, ["up", "-d", "--build"])) + yield* _(markDeployment(projectId, "running", "Recreate completed")) + }) + +export const readProjectPs = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + return yield* _(runComposeCapture(projectId, project.projectDir, ["ps"], [0])) + }) + +export const readProjectLogs = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + return yield* _(runComposeCapture(projectId, project.projectDir, ["logs", "--tail", "200"], [0, 1])) + }) + +export const resolveProjectById = findProjectById diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts new file mode 100644 index 00000000..55e47f32 --- /dev/null +++ b/packages/api/src/ui.ts @@ -0,0 +1,931 @@ +export const uiStyles = `@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); + +:root { + --bg: #f6f7f9; + --surface: rgba(255, 255, 255, 0.88); + --surface-strong: #ffffff; + --text: #12222f; + --muted: #516574; + --line: rgba(18, 34, 47, 0.14); + --accent: #0466c8; + --accent-2: #0096c7; + --danger: #b42318; + --ok: #117a65; + --shadow: 0 16px 42px rgba(7, 31, 51, 0.12); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + color: var(--text); + background: + radial-gradient(circle at 10% -10%, rgba(4, 102, 200, 0.18), transparent 45%), + radial-gradient(circle at 90% 0%, rgba(0, 150, 199, 0.16), transparent 40%), + linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%); +} + +body { + min-height: 100vh; +} + +.app-shell { + width: min(1260px, 100% - 2rem); + margin: 1rem auto 2rem; +} + +.hero { + padding: 1rem 0; +} + +.hero h1 { + margin: 0; + font-size: clamp(1.4rem, 2vw + 0.6rem, 2.2rem); + letter-spacing: 0.01em; +} + +.hero p { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.toolbar { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + align-items: center; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.8rem; +} + +.toolbar input { + flex: 1 1 260px; +} + +.grid { + display: grid; + grid-template-columns: minmax(300px, 0.95fr) minmax(340px, 1.2fr); + gap: 0.9rem; + margin-top: 0.9rem; +} + +.stack { + display: grid; + gap: 0.9rem; +} + +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + padding: 0.72rem 0.85rem; + border-bottom: 1px solid var(--line); + background: linear-gradient(135deg, rgba(4, 102, 200, 0.06), rgba(0, 150, 199, 0.04)); +} + +.panel-head h2, +.panel-head h3 { + margin: 0; + font-size: 1rem; +} + +.panel-body { + padding: 0.82rem; +} + +label { + font-size: 0.8rem; + color: var(--muted); + display: block; + margin-bottom: 0.2rem; +} + +input, +select, +textarea, +button { + font: inherit; +} + +input, +select, +textarea { + width: 100%; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--surface-strong); + padding: 0.52rem 0.6rem; + color: var(--text); +} + +textarea, +pre, +code, +.output, +.events { + font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +textarea { + min-height: 88px; + resize: vertical; +} + +.row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.55rem; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +button { + border: 0; + border-radius: 999px; + padding: 0.45rem 0.86rem; + font-weight: 600; + cursor: pointer; + color: #fff; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); +} + +button[data-variant="ghost"] { + color: var(--text); + border: 1px solid var(--line); + background: #fff; +} + +button[data-variant="danger"] { + background: linear-gradient(135deg, #b42318, #da3f34); +} + +button[data-variant="ok"] { + background: linear-gradient(135deg, #0c8a5c, #0d9d68); +} + +button:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +.project-list { + display: grid; + gap: 0.45rem; + max-height: 350px; + overflow: auto; +} + +.project-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.55rem; + background: #fff; +} + +.project-card.active { + border-color: rgba(4, 102, 200, 0.5); + box-shadow: 0 0 0 2px rgba(4, 102, 200, 0.12); +} + +.project-card .top { + display: flex; + justify-content: space-between; + gap: 0.4rem; + align-items: center; +} + +.badge { + border-radius: 999px; + padding: 0.1rem 0.5rem; + font-size: 0.75rem; + border: 1px solid var(--line); + background: #fff; +} + +.badge.running { + color: var(--ok); + border-color: rgba(17, 122, 101, 0.36); + background: rgba(17, 122, 101, 0.1); +} + +.badge.stopped { + color: #9a4d04; + border-color: rgba(154, 77, 4, 0.3); + background: rgba(154, 77, 4, 0.11); +} + +.kv { + display: grid; + grid-template-columns: 170px 1fr; + gap: 0.3rem 0.6rem; + font-size: 0.85rem; +} + +.kv .k { + color: var(--muted); +} + +pre { + margin: 0; + white-space: pre-wrap; + border: 1px solid var(--line); + border-radius: 10px; + background: #f9fcff; + padding: 0.68rem; + max-height: 240px; + overflow: auto; +} + +.events, +.output { + border: 1px solid var(--line); + border-radius: 10px; + background: #0f1a23; + color: #d8e7f5; + padding: 0.68rem; + min-height: 130px; + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + font-size: 0.8rem; +} + +.agents { + display: grid; + gap: 0.6rem; +} + +.agent-item { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.56rem; + background: #fff; + display: grid; + gap: 0.45rem; +} + +.agent-item .top { + display: flex; + justify-content: space-between; + gap: 0.4rem; + align-items: center; +} + +.small { + font-size: 0.8rem; + color: var(--muted); +} + +.mono { + font-family: "IBM Plex Mono", ui-monospace, monospace; + word-break: break-word; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.checkbox-row input { + width: auto; +} + +@media (max-width: 980px) { + .grid { + grid-template-columns: 1fr; + } + + .row { + grid-template-columns: 1fr; + } + + .kv { + grid-template-columns: 1fr; + } +} +` + +export const uiHtml = ` + + + + + docker-git API Console + + + +
+
+

docker-git API Console

+

UI-обвязка для тестирования v1 API без CLI

+
+ +
+
+ + +
+ + +
+ +
+
+
+
+

Создать проект

+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + + + + +
+
+ +
+
+
+ +
+
+

Проекты

+ 0 +
+
+
+
+
+
+ +
+
+
+

Проект

+ not selected +
+
+
+ +
+ + + + + + +
+ + +

+
+              
+ + +
+ +
+
+
+ +
+
+

Агенты

+ +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+ +
+
+

Debug output

+ +
+
+
+
+
+
+ + + + +` + +export const uiScript = ` +(() => { + const state = { + baseUrl: '', + projectId: '', + project: null, + projects: [], + agents: [], + eventSource: null, + eventCursor: 0 + }; + + const byId = (id) => document.getElementById(id); + + const views = { + baseUrl: byId('base-url'), + projectsCount: byId('projects-count'), + projectsList: byId('projects-list'), + activeProjectId: byId('active-project-id'), + projectDetails: byId('project-details'), + projectOutput: byId('project-output'), + eventsLog: byId('events-log'), + debugOutput: byId('debug-output'), + agentProvider: byId('agent-provider'), + agentLabel: byId('agent-label'), + agentCommand: byId('agent-command'), + agentCwd: byId('agent-cwd'), + agentEnv: byId('agent-env'), + agentsList: byId('agents-list'), + createRepoUrl: byId('create-repo-url'), + createRepoRef: byId('create-repo-ref'), + createSshPort: byId('create-ssh-port'), + createNetworkMode: byId('create-network-mode'), + createUp: byId('create-up'), + createForce: byId('create-force'), + createForceEnv: byId('create-force-env') + }; + + const appendDebug = (label, payload) => { + const stamp = new Date().toISOString(); + const line = '[' + stamp + '] ' + label + '\\n' + (typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); + views.debugOutput.textContent = (line + '\\n\\n' + views.debugOutput.textContent).slice(0, 24000); + }; + + const normalizeBase = (value) => { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return window.location.origin; + } + return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; + }; + + const projectPath = (projectId, suffix) => '/projects/' + encodeURIComponent(projectId) + suffix; + + const request = async (path, init) => { + const base = normalizeBase(views.baseUrl.value); + state.baseUrl = base; + const url = base + path; + const response = await fetch(url, init || {}); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch (_error) { + json = { raw: text }; + } + + if (!response.ok) { + appendDebug('HTTP ' + response.status + ' ' + path, json); + throw new Error((json && json.error && json.error.message) || ('HTTP ' + response.status)); + } + + appendDebug('HTTP ' + response.status + ' ' + path, json || text); + return json; + }; + + const setProjectOutput = (value) => { + views.projectOutput.textContent = value || ''; + }; + + const renderProjectDetails = () => { + views.activeProjectId.textContent = state.projectId || 'not selected'; + if (!state.project) { + views.projectDetails.innerHTML = '
Выберите проект слева
'; + return; + } + + const details = [ + ['displayName', state.project.displayName], + ['repo', state.project.repoUrl + ' @ ' + state.project.repoRef], + ['status', state.project.status + ' (' + state.project.statusLabel + ')'], + ['container', state.project.containerName], + ['service', state.project.serviceName], + ['ssh', state.project.sshCommand], + ['targetDir', state.project.targetDir] + ]; + + views.projectDetails.innerHTML = details.map(([k, v]) => '
' + k + '
' + String(v) + '
').join(''); + }; + + const renderProjects = () => { + views.projectsCount.textContent = String(state.projects.length); + if (state.projects.length === 0) { + views.projectsList.innerHTML = '
Проекты не найдены
'; + return; + } + + views.projectsList.innerHTML = state.projects.map((item) => { + const activeClass = item.id === state.projectId ? ' active' : ''; + const badgeClass = item.status === 'running' ? 'running' : (item.status === 'stopped' ? 'stopped' : ''); + return [ + '
', + '
', + '' + item.displayName + '', + '' + item.status + '', + '
', + '
' + item.repoRef + '
', + '
', + '
' + ].join(''); + }).join(''); + + views.projectsList.querySelectorAll('button[data-project-id]').forEach((button) => { + button.addEventListener('click', () => { + selectProject(button.getAttribute('data-project-id') || ''); + }); + }); + }; + + const loadProjects = async () => { + const payload = await request('/projects'); + state.projects = (payload && payload.projects) || []; + renderProjects(); + + if (!state.projectId && state.projects.length > 0) { + await selectProject(state.projects[0].id); + } + }; + + const loadProject = async () => { + if (!state.projectId) { + return; + } + const payload = await request(projectPath(state.projectId, '')); + state.project = payload.project; + renderProjectDetails(); + }; + + const selectProject = async (projectId) => { + if (!projectId) { + return; + } + state.projectId = projectId; + renderProjects(); + await loadProject(); + await loadAgents(); + }; + + const loadAgents = async () => { + if (!state.projectId) { + views.agentsList.innerHTML = '
Сначала выберите проект
'; + return; + } + + const payload = await request(projectPath(state.projectId, '/agents')); + state.agents = (payload && payload.sessions) || []; + renderAgents(); + }; + + const renderAgents = () => { + if (!state.projectId) { + views.agentsList.innerHTML = '
Сначала выберите проект
'; + return; + } + + if (state.agents.length === 0) { + views.agentsList.innerHTML = '
Агенты не запущены
'; + return; + } + + views.agentsList.innerHTML = state.agents.map((agent) => { + return [ + '
', + '
', + '' + agent.label + '', + '' + agent.status + '', + '
', + '
' + agent.id + '
', + '
' + agent.command + '
', + '
', + '', + '', + '', + '
', + '
' + ].join(''); + }).join(''); + + views.agentsList.querySelectorAll('button[data-action]').forEach((button) => { + button.addEventListener('click', async () => { + const action = button.getAttribute('data-action') || ''; + const agentId = button.getAttribute('data-agent-id') || ''; + if (!agentId || !state.projectId) { + return; + } + + if (action === 'stop') { + await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/stop'), { method: 'POST' }); + await loadAgents(); + return; + } + + if (action === 'logs') { + const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/logs?lines=250')); + const lines = (payload.entries || []).map((entry) => entry.at + ' [' + entry.stream + '] ' + entry.line); + setProjectOutput(lines.join('\\n')); + return; + } + + if (action === 'attach') { + const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/attach')); + setProjectOutput(JSON.stringify(payload.attach, null, 2)); + } + }); + }); + }; + + const clearEvents = () => { + views.eventsLog.textContent = ''; + }; + + const appendEvent = (event, payload) => { + const line = event + ' ' + JSON.stringify(payload); + views.eventsLog.textContent = (line + '\\n' + views.eventsLog.textContent).slice(0, 24000); + }; + + const stopEventStream = () => { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + appendEvent('system', { message: 'events stopped' }); + } + }; + + const startEventStream = () => { + if (!state.projectId) { + throw new Error('Выберите проект перед запуском SSE'); + } + + stopEventStream(); + const base = normalizeBase(views.baseUrl.value); + const url = base + projectPath(state.projectId, '/events?cursor=' + state.eventCursor); + const source = new EventSource(url); + state.eventSource = source; + + source.onmessage = (event) => { + if (!event.data) { + return; + } + try { + const payload = JSON.parse(event.data); + if (payload && payload.seq) { + state.eventCursor = payload.seq; + } + appendEvent(event.type || 'message', payload); + } catch (_error) { + appendEvent(event.type || 'message', event.data); + } + }; + + source.addEventListener('snapshot', (event) => { + try { + const payload = JSON.parse(event.data || '{}'); + state.eventCursor = payload.cursor || state.eventCursor; + appendEvent('snapshot', payload); + } catch (_error) { + appendEvent('snapshot', event.data || ''); + } + }); + + source.onerror = () => { + appendEvent('system', { message: 'events connection error' }); + }; + + appendEvent('system', { message: 'events started', url }); + }; + + const actionProject = async (suffix, method) => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + await request(projectPath(state.projectId, suffix), { method: method || 'POST' }); + await loadProject(); + await loadProjects(); + }; + + const runProjectRead = async (suffix) => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + const payload = await request(projectPath(state.projectId, suffix)); + setProjectOutput(payload.output || ''); + }; + + const parseEnvLines = (raw) => { + return String(raw || '') + .split(/\\r?\\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line.includes('=')) + .map((line) => { + const idx = line.indexOf('='); + return { key: line.slice(0, idx).trim(), value: line.slice(idx + 1) }; + }) + .filter((entry) => entry.key.length > 0); + }; + + const createProject = async () => { + const body = { + repoUrl: views.createRepoUrl.value.trim() || undefined, + repoRef: views.createRepoRef.value.trim() || undefined, + sshPort: views.createSshPort.value.trim() || undefined, + dockerNetworkMode: views.createNetworkMode.value.trim() || undefined, + up: views.createUp.checked, + force: views.createForce.checked, + forceEnv: views.createForceEnv.checked, + openSsh: false + }; + + await request('/projects', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + + await loadProjects(); + }; + + const createAgent = async () => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + + const body = { + provider: views.agentProvider.value, + label: views.agentLabel.value.trim() || undefined, + command: views.agentCommand.value.trim() || undefined, + cwd: views.agentCwd.value.trim() || undefined, + env: parseEnvLines(views.agentEnv.value) + }; + + await request(projectPath(state.projectId, '/agents'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + + await loadAgents(); + }; + + const withUiError = (fn) => async () => { + try { + await fn(); + } catch (error) { + appendDebug('UI error', String(error)); + window.alert(String(error)); + } + }; + + const wireActions = () => { + byId('btn-clear-output').addEventListener('click', () => { + views.debugOutput.textContent = ''; + views.eventsLog.textContent = ''; + views.projectOutput.textContent = ''; + }); + + byId('btn-health').addEventListener('click', withUiError(async () => { + const payload = await request('/health'); + window.alert('Health: ' + JSON.stringify(payload)); + })); + + byId('btn-projects-refresh').addEventListener('click', withUiError(loadProjects)); + byId('btn-create-project').addEventListener('click', withUiError(createProject)); + + byId('btn-up').addEventListener('click', withUiError(() => actionProject('/up', 'POST'))); + byId('btn-down').addEventListener('click', withUiError(() => actionProject('/down', 'POST'))); + byId('btn-recreate').addEventListener('click', withUiError(() => actionProject('/recreate', 'POST'))); + byId('btn-delete').addEventListener('click', withUiError(async () => { + if (!state.projectId) { + throw new Error('Сначала выберите проект'); + } + const ok = window.confirm('Удалить проект ' + state.projectId + '?'); + if (!ok) { + return; + } + await request(projectPath(state.projectId, ''), { method: 'DELETE' }); + stopEventStream(); + state.projectId = ''; + state.project = null; + state.agents = []; + renderProjectDetails(); + renderAgents(); + await loadProjects(); + })); + + byId('btn-ps').addEventListener('click', withUiError(() => runProjectRead('/ps'))); + byId('btn-logs').addEventListener('click', withUiError(() => runProjectRead('/logs'))); + + byId('btn-events-start').addEventListener('click', withUiError(async () => { + clearEvents(); + startEventStream(); + })); + byId('btn-events-stop').addEventListener('click', () => stopEventStream()); + + byId('btn-agent-start').addEventListener('click', withUiError(createAgent)); + byId('btn-agents-refresh').addEventListener('click', withUiError(loadAgents)); + }; + + const bootstrap = async () => { + views.baseUrl.value = window.location.origin; + wireActions(); + renderProjectDetails(); + renderAgents(); + await loadProjects(); + }; + + window.addEventListener('beforeunload', () => stopEventStream()); + + bootstrap().catch((error) => { + appendDebug('bootstrap failure', String(error)); + }); +})(); +` diff --git a/packages/api/tests/events.test.ts b/packages/api/tests/events.test.ts new file mode 100644 index 00000000..de8eedd6 --- /dev/null +++ b/packages/api/tests/events.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { clearProjectEvents, emitProjectEvent, latestProjectCursor, listProjectEventsSince } from "../src/services/events.js" + +describe("events service", () => { + it.effect("keeps monotonic cursor per project", () => + Effect.sync(() => { + const projectId = "project-a" + clearProjectEvents(projectId) + + const first = emitProjectEvent(projectId, "project.deployment.status", { phase: "build" }) + const second = emitProjectEvent(projectId, "project.deployment.log", { line: "ok" }) + + expect(first.seq).toBe(1) + expect(second.seq).toBe(2) + expect(latestProjectCursor(projectId)).toBe(2) + + const next = listProjectEventsSince(projectId, 1) + expect(next).toHaveLength(1) + expect(next[0]?.seq).toBe(2) + })) +}) diff --git a/packages/api/tests/federation.test.ts b/packages/api/tests/federation.test.ts new file mode 100644 index 00000000..cc6c2d92 --- /dev/null +++ b/packages/api/tests/federation.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { + clearFederationState, + createFollowSubscription, + ingestFederationInbox, + listFederationIssues, + listFollowSubscriptions, + makeFederationActorDocument, + makeFederationContext, + makeFederationFollowingCollection +} from "../src/services/federation.js" + +describe("federation service", () => { + it.effect("ingests ForgeFed Offer with Ticket payload", () => + Effect.gen(function*(_) { + clearFederationState() + + const result = yield* _( + ingestFederationInbox({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://forgefed.org/ns" + ], + id: "https://tracker.example/offers/42", + type: "Offer", + target: "https://tracker.example/issues", + object: { + type: "Ticket", + id: "https://tracker.example/issues/42", + attributedTo: "https://origin.example/users/alice", + summary: "Need reproducible CI parity", + content: "Implement API behavior matching CLI." + } + }) + ) + + expect(result.kind).toBe("issue.offer") + if (result.kind === "issue.offer") { + expect(result.issue.issueId).toBe("https://tracker.example/issues/42") + expect(result.issue.status).toBe("offered") + } + + const issues = listFederationIssues() + expect(issues).toHaveLength(1) + expect(issues[0]?.tracker).toBe("https://tracker.example/issues") + })) + + it.effect("creates follow subscription and resolves it via Accept activity", () => + Effect.gen(function*(_) { + clearFederationState() + + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "docker-git" + }) + ) + + const created = yield* _( + createFollowSubscription( + { + object: "https://tracker.provercoder.ai/issues/followers", + capability: "https://tracker.provercoder.ai/caps/follow", + to: ["https://www.w3.org/ns/activitystreams#Public"] + }, + context + ) + ) + + expect(created.subscription.status).toBe("pending") + expect(created.activity.type).toBe("Follow") + expect(created.activity.id).toContain("https://social.provercoder.ai/federation/activities/follows/") + expect(created.activity.actor).toBe("https://social.provercoder.ai/federation/actor") + + const accepted = yield* _( + ingestFederationInbox({ + type: "Accept", + actor: "https://tracker.example/system", + object: created.activity.id + }) + ) + + expect(accepted.kind).toBe("follow.accept") + if (accepted.kind === "follow.accept") { + expect(accepted.subscription.status).toBe("accepted") + } + + const follows = listFollowSubscriptions() + expect(follows).toHaveLength(1) + expect(follows[0]?.status).toBe("accepted") + })) + + it.effect("replaces .example host by configured domain", () => + Effect.gen(function*(_) { + clearFederationState() + + const context = yield* _( + makeFederationContext({ + publicOrigin: "social.provercoder.ai" + }) + ) + + const created = yield* _( + createFollowSubscription( + { + actor: "https://dev.example/users/bot", + object: "https://tracker.example/issues/followers", + inbox: "/federation/inbox" + }, + context + ) + ) + + expect(created.activity.actor).toBe("https://social.provercoder.ai/users/bot") + expect(created.activity.object).toBe("https://social.provercoder.ai/issues/followers") + expect(created.subscription.inbox).toBe("https://social.provercoder.ai/federation/inbox") + })) + + it.effect("builds person and following collections in activitypub shape", () => + Effect.gen(function*(_) { + clearFederationState() + + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai", + actorUsername: "tasks" + }) + ) + + const person = makeFederationActorDocument(context) + expect(person.type).toBe("Person") + expect(person.id).toBe("https://social.provercoder.ai/federation/actor") + expect(person.preferredUsername).toBe("tasks") + expect(person.followers).toBe("https://social.provercoder.ai/federation/followers") + + const created = yield* _( + createFollowSubscription( + { + object: "https://tracker.provercoder.ai/issues/followers" + }, + context + ) + ) + + yield* _( + ingestFederationInbox({ + type: "Accept", + object: created.activity.id + }) + ) + + const following = makeFederationFollowingCollection(context) + expect(following.type).toBe("OrderedCollection") + expect(following.totalItems).toBe(1) + expect(following.orderedItems[0]).toBe("https://tracker.provercoder.ai/issues/followers") + })) + + it.effect("rejects duplicate pending follow subscription", () => + Effect.gen(function*(_) { + clearFederationState() + + const context = yield* _( + makeFederationContext({ + publicOrigin: "https://social.provercoder.ai" + }) + ) + + const request = { + object: "https://tracker.provercoder.ai/issues/followers" + } as const + + yield* _(createFollowSubscription(request, context)) + + const duplicateError = yield* _( + createFollowSubscription(request, context).pipe(Effect.flip) + ) + + expect(duplicateError._tag).toBe("ApiConflictError") + })) +}) diff --git a/packages/api/tests/schema.test.ts b/packages/api/tests/schema.test.ts new file mode 100644 index 00000000..2e77bc5b --- /dev/null +++ b/packages/api/tests/schema.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Either, ParseResult, Schema } from "effect" + +import { CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema } from "../src/api/schema.js" + +describe("api schemas", () => { + it.effect("decodes create project payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CreateProjectRequestSchema)({ + repoUrl: "https://github.com/ProverCoderAI/docker-git", + repoRef: "main", + up: true, + force: false + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.repoRef).toBe("main") + expect(value.up).toBe(true) + } + }) + })) + + it.effect("rejects invalid agent provider", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CreateAgentRequestSchema)({ + provider: "wrong", + command: "codex" + }) + + Either.match(result, { + onLeft: (error) => { + expect(ParseResult.TreeFormatter.formatIssueSync(error.issue)).toContain("Expected \"codex\"") + }, + onRight: () => { + throw new Error("Expected schema decode failure") + } + }) + })) + + it.effect("decodes follow payload", () => + Effect.sync(() => { + const result = Schema.decodeUnknownEither(CreateFollowRequestSchema)({ + domain: "social.my-domain.tld", + object: "/issues/followers", + to: ["https://www.w3.org/ns/activitystreams#Public"] + }) + + Either.match(result, { + onLeft: (error) => { + throw new Error(ParseResult.TreeFormatter.formatIssueSync(error.issue)) + }, + onRight: (value) => { + expect(value.actor).toBeUndefined() + expect(value.domain).toBe("social.my-domain.tld") + expect(value.object).toBe("/issues/followers") + expect(value.to).toHaveLength(1) + } + }) + })) +}) diff --git a/packages/api/tests/ui.test.ts b/packages/api/tests/ui.test.ts new file mode 100644 index 00000000..c876de38 --- /dev/null +++ b/packages/api/tests/ui.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { uiHtml, uiScript, uiStyles } from "../src/ui.js" + +describe("api ui wrapper", () => { + it.effect("contains basic shell and API hooks", () => + Effect.sync(() => { + expect(uiHtml).toContain("docker-git API Console") + expect(uiHtml).toContain("/ui/app.js") + expect(uiScript).toContain("/projects") + expect(uiStyles).toContain(".panel") + })) +}) diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 00000000..1b337c15 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "types": ["node", "vitest"] + }, + "include": ["src/**/*", "tests/**/*", "vitest.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 00000000..60aea824 --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.{test,spec}.ts"], + exclude: ["node_modules", "dist"] + } +}) diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts index 73e2192d..87ce1c9a 100644 --- a/packages/app/src/docker-git/menu-actions.ts +++ b/packages/app/src/docker-git/menu-actions.ts @@ -1,6 +1,7 @@ import { type MenuAction, type ProjectConfig } from "@effect-template/lib/core/domain" import { readProjectConfig } from "@effect-template/lib/shell/config" import { runDockerComposeDown, runDockerComposeLogs, runDockerComposePs } from "@effect-template/lib/shell/docker" +import { gcProjectNetworkByTemplate } from "@effect-template/lib/usecases/docker-network-gc" import type { AppError } from "@effect-template/lib/usecases/errors" import { renderError } from "@effect-template/lib/usecases/errors" import { @@ -9,7 +10,6 @@ import { listProjectStatus, listRunningProjectItems } from "@effect-template/lib/usecases/projects" -import { gcProjectNetworkByTemplate } from "@effect-template/lib/usecases/docker-network-gc" import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up" import { Effect, Match, pipe } from "effect" diff --git a/packages/app/src/docker-git/menu-shared.ts b/packages/app/src/docker-git/menu-shared.ts index 66e9faa6..06ae5187 100644 --- a/packages/app/src/docker-git/menu-shared.ts +++ b/packages/app/src/docker-git/menu-shared.ts @@ -45,12 +45,12 @@ const disableTerminalInputModes = (): void => { // Disable mouse/input modes that can leak across TUI <-> SSH transitions. process.stdout.write( "\u001B[0m" + - "\u001B[?25h" + - "\u001B[?1l" + - "\u001B>" + - "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + - "\u001B[?1004l\u001B[?2004l" + - "\u001B[>4;0m\u001B[>4m\u001B[" + + "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + + "\u001B[?1004l\u001B[?2004l" + + "\u001B[>4;0m\u001B[>4m\u001B[ /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh` const renderDockerfileBunPrelude = (config: TemplateConfig): string => - `# Tooling: pnpm + Codex CLI + oh-my-opencode (bun) + Claude Code CLI (npm) + `# Tooling: pnpm + Codex CLI (bun) + oh-my-opencode (npm + platform binary) + Claude Code CLI (npm) RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate ENV TERM=xterm-256color RUN set -eu; \ @@ -42,15 +42,23 @@ RUN set -eu; \ exit 0; \ fi; \ echo "bun install attempt \${attempt} failed; retrying..." >&2; \ - rm -f /tmp/bun-install.sh; \ + rm -f /tmp/bun-install.sh; \ sleep $((attempt * 2)); \ done; \ echo "bun install failed after retries" >&2; \ exit 1 RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun -RUN BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest oh-my-opencode@latest" /dev/null +RUN BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest" /dev/null RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex -RUN ln -sf /usr/local/bun/bin/oh-my-opencode /usr/local/bin/oh-my-opencode +RUN set -eu; \ + ARCH="$(uname -m)"; \ + case "$ARCH" in \ + x86_64|amd64) OH_MY_OPENCODE_ARCH="x64" ;; \ + aarch64|arm64) OH_MY_OPENCODE_ARCH="arm64" ;; \ + *) echo "Unsupported arch for oh-my-opencode: $ARCH" >&2; exit 1 ;; \ + esac; \ + npm install -g oh-my-opencode@latest "oh-my-opencode-linux-\${OH_MY_OPENCODE_ARCH}@latest" +RUN oh-my-opencode --version RUN npm install -g @anthropic-ai/claude-code@latest RUN claude --version` diff --git a/packages/lib/src/shell/docker-published-ports.ts b/packages/lib/src/shell/docker-published-ports.ts new file mode 100644 index 00000000..c318090f --- /dev/null +++ b/packages/lib/src/shell/docker-published-ports.ts @@ -0,0 +1,80 @@ +import { ExitCode } from "@effect/platform/CommandExecutor" +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import { Effect, pipe } from "effect" + +import { runCommandCapture } from "./command-runner.js" +import { CommandFailedError } from "./errors.js" + +const publishedHostPortPattern = /:(\d+)->/g + +const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray => { + const parsed: Array = [] + for (const match of line.matchAll(publishedHostPortPattern)) { + const rawPort = match[1] + if (rawPort === undefined) { + continue + } + const value = Number.parseInt(rawPort, 10) + if (Number.isInteger(value) && value > 0 && value <= 65_535) { + parsed.push(value) + } + } + return parsed +} + +// CHANGE: decode published host ports from `docker ps --format "{{.Ports}}"` output +// WHY: Docker can reserve host ports via NAT even when no host TCP socket is visible +// QUOTE(ТЗ): "должен просто новый порт брать под себя" +// REF: user-request-2026-02-19-port-allocation +// SOURCE: n/a +// FORMAT THEOREM: forall p in parse(output): published_by_docker(p) +// PURITY: CORE +// EFFECT: Effect, never, never> +// INVARIANT: returns unique ports in encounter order +// COMPLEXITY: O(|output|) +export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray => { + const unique = new Set() + const parsed: Array = [] + + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim() + if (trimmed.length === 0) { + continue + } + for (const port of parsePublishedHostPortsFromLine(trimmed)) { + if (!unique.has(port)) { + unique.add(port) + parsed.push(port) + } + } + } + + return parsed +} + +// CHANGE: read currently published Docker host ports from running containers +// WHY: avoid false "free port" results when Docker reserves ports without userland proxy sockets +// QUOTE(ТЗ): "а не сражаться за старый" +// REF: user-request-2026-02-19-port-allocation +// SOURCE: n/a +// FORMAT THEOREM: forall p in result: published_by_running_container(p) +// PURITY: SHELL +// EFFECT: Effect, CommandFailedError | PlatformError, CommandExecutor> +// INVARIANT: output ports are unique +// COMPLEXITY: O(command + |stdout|) +export const runDockerPsPublishedHostPorts = ( + cwd: string +): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => + pipe( + runCommandCapture( + { + cwd, + command: "docker", + args: ["ps", "--format", "{{.Ports}}"] + }, + [Number(ExitCode(0))], + (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) + ), + Effect.map((output) => parseDockerPublishedHostPorts(output)) + ) diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 81bfea4b..cb4898fe 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -8,6 +8,7 @@ import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from " import { CommandFailedError, DockerCommandError } from "./errors.js" export { classifyDockerAccessIssue, ensureDockerDaemonAccess } from "./docker-daemon-access.js" +export { parseDockerPublishedHostPorts, runDockerPsPublishedHostPorts } from "./docker-published-ports.js" const composeSpec = (cwd: string, args: ReadonlyArray) => ({ cwd, @@ -489,76 +490,3 @@ export const runDockerPsNames = ( .filter((line) => line.length > 0) ) ) - -const publishedHostPortPattern = /:(\d+)->/g - -const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray => { - const parsed: Array = [] - for (const match of line.matchAll(publishedHostPortPattern)) { - const rawPort = match[1] - if (rawPort === undefined) { - continue - } - const value = Number.parseInt(rawPort, 10) - if (Number.isInteger(value) && value > 0 && value <= 65_535) { - parsed.push(value) - } - } - return parsed -} - -// CHANGE: decode published host ports from `docker ps --format "{{.Ports}}"` output -// WHY: Docker can reserve host ports via NAT even when no host TCP socket is visible -// QUOTE(ТЗ): "должен просто новый порт брать под себя" -// REF: user-request-2026-02-19-port-allocation -// SOURCE: n/a -// FORMAT THEOREM: forall p in parse(output): published_by_docker(p) -// PURITY: CORE -// EFFECT: Effect, never, never> -// INVARIANT: returns unique ports in encounter order -// COMPLEXITY: O(|output|) -export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray => { - const unique = new Set() - const parsed: Array = [] - - for (const line of output.split(/\r?\n/)) { - const trimmed = line.trim() - if (trimmed.length === 0) { - continue - } - for (const port of parsePublishedHostPortsFromLine(trimmed)) { - if (!unique.has(port)) { - unique.add(port) - parsed.push(port) - } - } - } - - return parsed -} - -// CHANGE: read currently published Docker host ports from running containers -// WHY: avoid false "free port" results when Docker reserves ports without userland proxy sockets -// QUOTE(ТЗ): "а не сражаться за старый" -// REF: user-request-2026-02-19-port-allocation -// SOURCE: n/a -// FORMAT THEOREM: forall p in result: published_by_running_container(p) -// PURITY: SHELL -// EFFECT: Effect, CommandFailedError | PlatformError, CommandExecutor> -// INVARIANT: output ports are unique -// COMPLEXITY: O(command + |stdout|) -export const runDockerPsPublishedHostPorts = ( - cwd: string -): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => - pipe( - runCommandCapture( - { - cwd, - command: "docker", - args: ["ps", "--format", "{{.Ports}}"] - }, - [Number(ExitCode(0))], - (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) - ), - Effect.map((output) => parseDockerPublishedHostPorts(output)) - ) diff --git a/packages/lib/src/usecases/docker-network-gc.ts b/packages/lib/src/usecases/docker-network-gc.ts index e9f850ed..a7295dc0 100644 --- a/packages/lib/src/usecases/docker-network-gc.ts +++ b/packages/lib/src/usecases/docker-network-gc.ts @@ -2,11 +2,7 @@ import type { CommandExecutor } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import { Effect } from "effect" -import { - defaultTemplateConfig, - resolveComposeNetworkName, - type TemplateConfig -} from "../core/domain.js" +import { defaultTemplateConfig, resolveComposeNetworkName, type TemplateConfig } from "../core/domain.js" import { runDockerNetworkContainerCount, runDockerNetworkCreateBridge, @@ -21,19 +17,27 @@ const protectedNetworkNames = new Set(["bridge", "host", "none"]) const isProtectedNetwork = (networkName: string, sharedNetworkName: string): boolean => protectedNetworkNames.has(networkName) || networkName === sharedNetworkName -const sharedNetworkFallbackSubnets: ReadonlyArray = [ - "10.250.0.0/24", - "10.251.0.0/24", - "10.252.0.0/24", - "10.253.0.0/24", - "172.31.250.0/24", - "172.31.251.0/24", - "172.31.252.0/24", - "172.31.253.0/24", - "192.168.250.0/24", - "192.168.251.0/24" +type Subnet24Seed = readonly [number, number, number] + +const sharedNetworkFallbackSubnetSeeds: ReadonlyArray = [ + [10, 250, 0], + [10, 251, 0], + [10, 252, 0], + [10, 253, 0], + [172, 31, 250], + [172, 31, 251], + [172, 31, 252], + [172, 31, 253], + [192, 168, 250], + [192, 168, 251] ] +const formatSubnet24 = ([a, b, c]: Subnet24Seed): string => `${[a, b, c, 0].join(".")}/24` + +const sharedNetworkFallbackSubnets: ReadonlyArray = sharedNetworkFallbackSubnetSeeds.map((seed) => + formatSubnet24(seed) +) + const createSharedNetworkWithSubnetFallback = ( cwd: string, networkName: string @@ -46,8 +50,7 @@ const createSharedNetworkWithSubnetFallback = ( Effect.catchTag("DockerCommandError", (error) => Effect.logWarning( `Shared network create fallback failed (${networkName}, subnet ${subnet}, exit ${error.exitCode}); trying next subnet.` - ).pipe(Effect.as(false)) - ) + ).pipe(Effect.as(false))) ) ) if (created) { @@ -94,7 +97,8 @@ export const ensureComposeNetworkReady = ( ? Effect.void : Effect.log(`Creating shared Docker network: ${networkName}`).pipe( Effect.zipRight(ensureSharedNetworkExists(cwd, networkName)) - )) + ) + ) ) } @@ -169,5 +173,4 @@ export const gcProjectNetworkByServiceName = ( cwd: string, serviceName: string, sharedNetworkName: string = defaultTemplateConfig.dockerSharedNetworkName -): Effect.Effect => - gcNetworkByName(cwd, `${serviceName}-net`, sharedNetworkName) +): Effect.Effect => gcNetworkByName(cwd, `${serviceName}-net`, sharedNetworkName) diff --git a/packages/lib/src/usecases/terminal-cursor.ts b/packages/lib/src/usecases/terminal-cursor.ts index 9f5f6e16..a38f23e6 100644 --- a/packages/lib/src/usecases/terminal-cursor.ts +++ b/packages/lib/src/usecases/terminal-cursor.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" -const terminalSaneEscape = - "\u001B[0m" + // reset rendition +const terminalSaneEscape = "\u001B[0m" + // reset rendition "\u001B[?25h" + // show cursor "\u001B[?1l" + // normal cursor keys mode "\u001B>" + // normal keypad mode diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54742c91..68a1fb77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,52 @@ importers: specifier: ^2.29.8 version: 2.29.8(@types/node@24.10.9) + packages/api: + dependencies: + '@effect-template/lib': + specifier: workspace:* + version: link:../lib + '@effect/platform': + specifier: ^0.94.1 + version: 0.94.1(effect@3.19.14) + '@effect/platform-node': + specifier: ^0.104.0 + version: 0.104.0(@effect/cluster@0.56.1(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/workflow@0.16.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(@effect/rpc@0.73.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/sql@0.49.0(@effect/experimental@0.58.0(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(@effect/platform@0.94.1(effect@3.19.14))(effect@3.19.14))(effect@3.19.14) + '@effect/schema': + specifier: ^0.75.5 + version: 0.75.5(effect@3.19.14) + effect: + specifier: ^3.19.14 + version: 3.19.14 + devDependencies: + '@effect/vitest': + specifier: ^0.27.0 + version: 0.27.0(effect@3.19.14)(vitest@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + '@eslint/js': + specifier: 9.39.1 + version: 9.39.1 + '@types/node': + specifier: ^24.10.1 + version: 24.10.9 + '@typescript-eslint/eslint-plugin': + specifier: ^8.48.1 + version: 8.53.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.48.1 + version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.39.1 + version: 9.39.2(jiti@2.6.1) + globals: + specifier: ^16.5.0 + version: 16.5.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + packages/app: dependencies: '@effect/cli': @@ -849,6 +895,10 @@ packages: resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.39.2': resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1479,9 +1529,23 @@ packages: vitest: optional: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.17': resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.17': resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: @@ -1493,18 +1557,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.17': resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.17': resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.17': resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.17': resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.17': resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} @@ -1672,6 +1751,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1691,6 +1774,10 @@ packages: caniuse-lite@1.0.30001759: resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1721,6 +1808,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -1848,6 +1939,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2830,6 +2925,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3102,6 +3200,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3494,6 +3596,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3512,6 +3617,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3520,10 +3628,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3650,6 +3770,11 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-tsconfig-paths@6.0.4: resolution: {integrity: sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ==} peerDependencies: @@ -3698,6 +3823,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.17: resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4255,6 +4408,11 @@ snapshots: dependencies: effect: 3.19.14 + '@effect/vitest@0.27.0(effect@3.19.14)(vitest@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + dependencies: + effect: 3.19.14 + vitest: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + '@effect/vitest@0.27.0(effect@3.19.14)(vitest@4.0.17(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': dependencies: effect: 3.19.14 @@ -4421,6 +4579,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/js@9.39.1': {} + '@eslint/js@9.39.2': {} '@eslint/object-schema@2.1.7': {} @@ -5040,6 +5200,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.17': dependencies: '@standard-schema/spec': 1.0.0 @@ -5049,6 +5217,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + '@vitest/mocker@4.0.17(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.17 @@ -5057,23 +5233,49 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.17': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.17': dependencies: '@vitest/utils': 4.0.17 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.17': dependencies: '@vitest/pretty-format': 4.0.17 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.17': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.17': dependencies: '@vitest/pretty-format': 4.0.17 @@ -5249,6 +5451,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5270,6 +5474,14 @@ snapshots: caniuse-lite@1.0.30001759: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: @@ -5293,6 +5505,8 @@ snapshots: chardet@2.1.1: {} + check-error@2.1.3: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -5420,6 +5634,8 @@ snapshots: dedent@1.7.0: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -6577,6 +6793,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -6863,6 +7081,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -7325,6 +7545,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -7337,6 +7561,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -7344,8 +7570,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7496,6 +7728,27 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 + vite-node@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-tsconfig-paths@6.0.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)): dependencies: debug: 4.4.3 @@ -7522,6 +7775,47 @@ snapshots: lightningcss: 1.30.2 yaml: 2.8.2 + vitest@3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.9 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.17(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8f066e1..3c77a1ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: + - packages/api - packages/app - packages/lib