Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
dist/
coverage/
.vitest/
73 changes: 73 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
@@ -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/<projectId>/up
curl -s -N http://localhost:3334/projects/<projectId>/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"}}'
```
29 changes: 29 additions & 0 deletions packages/api/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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
}
}
]
38 changes: 38 additions & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
226 changes: 226 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
@@ -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<string> | undefined
readonly cwd?: string | undefined
readonly env?: ReadonlyArray<AgentEnvVar> | 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<string> | 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<string> | 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<unknown>
}

export type FollowSubscription = {
readonly id: string
readonly activityId: string
readonly actor: string
readonly object: string
readonly inbox?: string | undefined
readonly to: ReadonlyArray<string>
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
}
Loading