diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f0d049..e545840 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,26 @@ jobs: - run: bunx biome check . - run: bunx tsc --noEmit -p apps/api/tsconfig.json - run: cd apps/web && bunx tsc -b + - run: bunx tsc --noEmit -p packages/contract/tsconfig.json + - run: bunx tsc --noEmit -p packages/cli/tsconfig.json + - run: bunx tsc --noEmit -p packages/mcp/tsconfig.json + - run: bun test + # The MCP README tool table is generated from source; fail if stale. + - name: MCP tool docs up to date + run: cd packages/mcp && bun run scripts/generate-tool-docs.ts --check + # cli and mcp publish bundled output (prepack). Pack a dry-run tarball + # so a broken bundle or a stray workspace: specifier fails PR CI + # instead of the next release. + - name: CLI pack smoke test + run: | + cd packages/cli && npm pack --pack-destination /tmp + tar -xzOf /tmp/moor-sh-cli-*.tgz package/package.json | (! grep -q "workspace:") + tar -tzf /tmp/moor-sh-cli-*.tgz | grep -q "package/dist/index.js" + - name: MCP pack smoke test + run: | + cd packages/mcp && npm pack --pack-destination /tmp + tar -xzOf /tmp/moor-sh-mcp-*.tgz package/package.json | (! grep -q "workspace:") + tar -tzf /tmp/moor-sh-mcp-*.tgz | grep -q "package/dist/index.js" # #80 PR #3 follow-up: respawner build + self-test were added to # release-moor.yml's check job, but that only runs on push to main / # workflow_dispatch — PR CI was leaving the privileged image diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index dd0b029..da64690 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -6,6 +6,7 @@ on: branches: [main] paths: - "packages/cli/**" + - "packages/contract/**" jobs: check: @@ -16,6 +17,11 @@ jobs: - run: bun install --frozen-lockfile - run: bunx biome check packages/cli - run: cd packages/cli && bunx tsc --noEmit + - name: CLI pack smoke test + run: | + cd packages/cli && npm pack --pack-destination /tmp + tar -xzOf /tmp/moor-sh-cli-*.tgz package/package.json | (! grep -q "workspace:") + tar -tzf /tmp/moor-sh-cli-*.tgz | grep -q "package/dist/index.js" release: needs: check diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index e110f22..5b5bc7e 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -6,6 +6,7 @@ on: branches: [main] paths: - "packages/mcp/**" + - "packages/contract/**" jobs: check: @@ -16,6 +17,11 @@ jobs: - run: bun install --frozen-lockfile - run: bunx biome check packages/mcp - run: cd packages/mcp && bunx tsc --noEmit + - name: MCP pack smoke test + run: | + cd packages/mcp && npm pack --pack-destination /tmp + tar -xzOf /tmp/moor-sh-mcp-*.tgz package/package.json | (! grep -q "workspace:") + tar -tzf /tmp/moor-sh-mcp-*.tgz | grep -q "package/dist/index.js" release: needs: check diff --git a/.github/workflows/release-moor.yml b/.github/workflows/release-moor.yml index 43dfc8c..4483b11 100644 --- a/.github/workflows/release-moor.yml +++ b/.github/workflows/release-moor.yml @@ -8,6 +8,7 @@ on: - "apps/api/**" - "apps/web/**" - "apps/respawner/**" + - "packages/contract/**" - "Dockerfile" - "docker-compose.yml" - "package.json" diff --git a/Dockerfile b/Dockerfile index bf502cf..fcc0350 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,12 @@ COPY apps/web/package.json apps/web/ COPY apps/site/package.json apps/site/ COPY packages/cli/package.json packages/cli/ COPY packages/mcp/package.json packages/mcp/ +COPY packages/contract/package.json packages/contract/ RUN bun install --frozen-lockfile --ignore-scripts -# Build client +# Build client (imports types from the workspace contract package, +# so its source must be present before the web build) +COPY packages/contract/ packages/contract/ COPY apps/web/ apps/web/ COPY tsconfig.json . RUN cd apps/web && bun run build @@ -39,6 +42,7 @@ COPY apps/web/package.json apps/web/ COPY apps/site/package.json apps/site/ COPY packages/cli/package.json packages/cli/ COPY packages/mcp/package.json packages/mcp/ +COPY packages/contract/package.json packages/contract/ RUN bun install --frozen-lockfile --ignore-scripts --production # Copy built client and server source diff --git a/README.md b/README.md index d4307ce..b112334 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ Self-hosted Docker control panel for a single server. Build, deploy, and manage ## What it does -- Build Docker images from GitHub repos +- Build Docker images from GitHub repos, public or private +- Deploy from private registries (GHCR, Docker Hub, self-hosted) - Start, stop, restart, and rebuild containers - Stream build output and container logs in real time - Web terminal into running containers - Schedule cron jobs inside containers -- Manage environment variables per project +- Manage environment variables, persistent volumes, and injected files per project +- Override a container's command and entrypoint without a Dockerfile - Route custom domains to containers with HTTPS +- Container stats and stored run history for after-the-fact debugging +- Drain mode, self-update, DB backups, and image cleanup for host upkeep - CLI and MCP server for AI agent integration ## Prerequisites diff --git a/apps/api/db-migrations.test.ts b/apps/api/db-migrations.test.ts new file mode 100644 index 0000000..dfae33b --- /dev/null +++ b/apps/api/db-migrations.test.ts @@ -0,0 +1,237 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { finalSchemaVersion, type Migration, runMigrations } from "./db-migrations"; + +const BASELINE_SCHEMA_SQL = ` + CREATE TABLE projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + github_url TEXT, + branch TEXT DEFAULT 'main', + dockerfile TEXT DEFAULT 'Dockerfile', + image_tag TEXT, + container_id TEXT, + status TEXT DEFAULT 'stopped', + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE crons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + schedule TEXT NOT NULL, + command TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_id INTEGER REFERENCES crons(id) ON DELETE SET NULL, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + started_at TEXT DEFAULT (datetime('now')), + finished_at TEXT, + exit_code INTEGER, + stdout TEXT, + stderr TEXT, + duration_ms INTEGER + ); + + CREATE TABLE exec_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + command TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'running', + exit_code INTEGER, + stdout TEXT NOT NULL DEFAULT '', + stderr TEXT NOT NULL DEFAULT '', + stdout_total_bytes INTEGER NOT NULL DEFAULT 0, + stderr_total_bytes INTEGER NOT NULL DEFAULT 0, + timeout_ms INTEGER NOT NULL, + killed_pid TEXT, + error_message TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT, + started_at_ms INTEGER, + finished_at_ms INTEGER + ); + + CREATE TABLE source_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hostname TEXT NOT NULL, + label TEXT NOT NULL, + username TEXT NOT NULL, + secret TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'failed')), + expires_at TEXT, + last_checked_at TEXT, + last_check_status TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(hostname, label) + ); +`; + +type MigrationTable = "projects" | "runs" | "exec_runs"; + +function withBaselineDatabase(run: (db: Database) => void): void { + const db = new Database(":memory:"); + try { + db.exec("PRAGMA foreign_keys = ON"); + db.exec(BASELINE_SCHEMA_SQL); + run(db); + } finally { + db.close(); + } +} + +function withEmptyDatabase(run: (db: Database) => void): void { + const db = new Database(":memory:"); + try { + run(db); + } finally { + db.close(); + } +} + +function userVersion(db: Database): number { + const row = db.query("PRAGMA user_version").get() as { user_version: number }; + return row.user_version; +} + +function columnNames(db: Database, table: MigrationTable): Set { + const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + return new Set(rows.map((row) => row.name)); +} + +function expectColumns(db: Database, table: MigrationTable, columns: readonly string[]): void { + const names = columnNames(db, table); + for (const column of columns) { + expect(names.has(column)).toBe(true); + } +} + +function addLegacyMigrationColumns(db: Database): void { + db.exec(` + ALTER TABLE projects ADD COLUMN docker_image TEXT; + ALTER TABLE projects ADD COLUMN domain TEXT; + ALTER TABLE projects ADD COLUMN domain_port INTEGER; + ALTER TABLE projects ADD COLUMN restart_policy TEXT DEFAULT 'unless-stopped'; + ALTER TABLE projects ADD COLUMN memory_limit_mb INTEGER; + ALTER TABLE projects ADD COLUMN cpus REAL; + ALTER TABLE projects ADD COLUMN live_status TEXT; + ALTER TABLE projects ADD COLUMN live_exit_code INTEGER; + ALTER TABLE projects ADD COLUMN live_checked_at TEXT; + ALTER TABLE projects ADD COLUMN live_error TEXT; + ALTER TABLE runs ADD COLUMN started_at_ms INTEGER; + ALTER TABLE runs ADD COLUMN finished_at_ms INTEGER; + ALTER TABLE runs ADD COLUMN stdout_total_bytes INTEGER; + ALTER TABLE runs ADD COLUMN stderr_total_bytes INTEGER; + ALTER TABLE projects ADD COLUMN source_credential_id INTEGER REFERENCES source_credentials(id); + ALTER TABLE projects ADD COLUMN command TEXT; + ALTER TABLE projects ADD COLUMN entrypoint TEXT; + `); +} + +describe("schema migrations", () => { + test("fresh in-memory baseline reaches the final schema", () => { + withBaselineDatabase((db) => { + expect(userVersion(db)).toBe(0); + + runMigrations(db); + + expect(userVersion(db)).toBe(finalSchemaVersion); + expectColumns(db, "projects", [ + "docker_image", + "domain", + "domain_port", + "restart_policy", + "memory_limit_mb", + "cpus", + "live_status", + "live_exit_code", + "live_checked_at", + "live_error", + "source_credential_id", + "command", + "entrypoint", + ]); + expectColumns(db, "runs", [ + "started_at_ms", + "finished_at_ms", + "stdout_total_bytes", + "stderr_total_bytes", + ]); + expectColumns(db, "exec_runs", ["started_at_ms", "finished_at_ms"]); + }); + }); + + test("pre-versioning database with existing columns records migration versions", () => { + withBaselineDatabase((db) => { + addLegacyMigrationColumns(db); + const project = db + .query("INSERT INTO projects (name) VALUES ('legacy') RETURNING id") + .get() as { + id: number; + }; + db.query( + `INSERT INTO exec_runs + (project_id, command, timeout_ms, started_at, finished_at, started_at_ms, finished_at_ms) + VALUES (?, 'legacy-exec', 60000, '2025-01-01 12:34:56', '2025-01-01 12:35:00', NULL, NULL)`, + ).run(project.id); + db.query( + `INSERT INTO runs + (project_id, started_at, finished_at, stdout, stderr, + started_at_ms, finished_at_ms, stdout_total_bytes, stderr_total_bytes) + VALUES (?, '2025-01-02 01:02:03', '2025-01-02 01:02:04', 'hello', 'warn', + NULL, NULL, NULL, NULL)`, + ).run(project.id); + + runMigrations(db); + + expect(userVersion(db)).toBe(finalSchemaVersion); + const execRun = db + .query("SELECT started_at_ms, finished_at_ms FROM exec_runs WHERE command = 'legacy-exec'") + .get() as { started_at_ms: number; finished_at_ms: number }; + expect(execRun.started_at_ms).toBe(Date.UTC(2025, 0, 1, 12, 34, 56)); + expect(execRun.finished_at_ms).toBe(Date.UTC(2025, 0, 1, 12, 35, 0)); + + const run = db + .query( + "SELECT started_at_ms, finished_at_ms, stdout_total_bytes, stderr_total_bytes FROM runs", + ) + .get() as { + started_at_ms: number; + finished_at_ms: number; + stdout_total_bytes: number; + stderr_total_bytes: number; + }; + expect(run.started_at_ms).toBe(Date.UTC(2025, 0, 2, 1, 2, 3)); + expect(run.finished_at_ms).toBe(Date.UTC(2025, 0, 2, 1, 2, 4)); + expect(run.stdout_total_bytes).toBe("hello".length); + expect(run.stderr_total_bytes).toBe("warn".length); + }); + }); + + test("failing migration throws and rolls back its version", () => { + withEmptyDatabase((db) => { + const failingMigration: Migration = { + version: 1, + up(database) { + database.exec("CREATE TABLE partial_migration (id INTEGER PRIMARY KEY)"); + database.exec("SELECT * FROM missing_table"); + }, + }; + + expect(() => runMigrations(db, [failingMigration])).toThrow(); + expect(userVersion(db)).toBe(0); + expect( + db + .query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'partial_migration'", + ) + .get(), + ).toBeNull(); + }); + }); +}); diff --git a/apps/api/db-migrations.ts b/apps/api/db-migrations.ts new file mode 100644 index 0000000..680e409 --- /dev/null +++ b/apps/api/db-migrations.ts @@ -0,0 +1,334 @@ +import type { Database } from "bun:sqlite"; + +export type Migration = { + version: number; + up: (db: Database) => void; +}; + +type TableInfoRow = { + name: string; +}; + +function quoteIdentifier(identifier: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) { + throw new Error(`Invalid SQLite identifier: ${identifier}`); + } + return `"${identifier}"`; +} + +function hasColumn(db: Database, table: string, column: string): boolean { + const rows = db.query(`PRAGMA table_info(${quoteIdentifier(table)})`).all() as TableInfoRow[]; + return rows.some((row) => row.name === column); +} + +function addColumnIfMissing(db: Database, table: string, column: string, sql: string): void { + if (hasColumn(db, table, column)) { + return; + } + + db.exec(sql); +} + +function getUserVersion(db: Database): number { + const row = db.query("PRAGMA user_version").get() as { user_version: number } | null; + return row?.user_version ?? 0; +} + +function setUserVersion(db: Database, version: number): void { + db.exec(`PRAGMA user_version = ${version}`); +} + +export const schemaMigrations: readonly Migration[] = [ + { + version: 1, + up(db) { + addColumnIfMissing( + db, + "projects", + "docker_image", + "ALTER TABLE projects ADD COLUMN docker_image TEXT", + ); + }, + }, + { + version: 2, + up(db) { + addColumnIfMissing(db, "projects", "domain", "ALTER TABLE projects ADD COLUMN domain TEXT"); + }, + }, + { + version: 3, + up(db) { + addColumnIfMissing( + db, + "projects", + "domain_port", + "ALTER TABLE projects ADD COLUMN domain_port INTEGER", + ); + }, + }, + { + version: 4, + up(db) { + addColumnIfMissing( + db, + "projects", + "restart_policy", + "ALTER TABLE projects ADD COLUMN restart_policy TEXT DEFAULT 'unless-stopped'", + ); + }, + }, + + // #45: millisecond timestamps for exec_runs. New rows write Date.now() from + // JS in exec-async.ts. Old rows are backfilled best-effort from the text + // columns, which are SQLite second-precision so the backfilled values are + // snapped to the start of their wall-clock second (good enough for runs that + // pre-date this migration). New rows get true millisecond precision. + { + version: 5, + up(db) { + addColumnIfMissing( + db, + "exec_runs", + "started_at_ms", + "ALTER TABLE exec_runs ADD COLUMN started_at_ms INTEGER", + ); + db.exec( + "UPDATE exec_runs SET started_at_ms = CAST(strftime('%s', started_at) AS INTEGER) * 1000 WHERE started_at_ms IS NULL AND started_at IS NOT NULL", + ); + }, + }, + { + version: 6, + up(db) { + addColumnIfMissing( + db, + "exec_runs", + "finished_at_ms", + "ALTER TABLE exec_runs ADD COLUMN finished_at_ms INTEGER", + ); + db.exec( + "UPDATE exec_runs SET finished_at_ms = CAST(strftime('%s', finished_at) AS INTEGER) * 1000 WHERE finished_at_ms IS NULL AND finished_at IS NOT NULL", + ); + }, + }, + + // #36: per-project memory and CPU limits. NULL = unbounded (current behavior, + // no Docker HostConfig fields set). When set: memory_limit_mb maps to Memory + // (and equal MemorySwap so the container can't burn through host swap) and + // cpus maps to NanoCpus (cpus * 1e9). Limits take effect on container + // recreate — handleStart/handleRun all call createAndStartContainer which + // force-removes the existing container by name and creates fresh. + { + version: 7, + up(db) { + addColumnIfMissing( + db, + "projects", + "memory_limit_mb", + "ALTER TABLE projects ADD COLUMN memory_limit_mb INTEGER", + ); + }, + }, + { + version: 8, + up(db) { + addColumnIfMissing(db, "projects", "cpus", "ALTER TABLE projects ADD COLUMN cpus REAL"); + }, + }, + + // #71: dual-field model for runtime truth. projects.status stays moor's + // *recorded* state (changes only on explicit moor actions: start/stop/ + // build/cancel). The live_* fields are written by the status reconciler + // background loop and reflect Docker's view at last successful inspect. + // Both directions matter — DB can drift from Docker (missed exit) and + // Docker can drift from DB (recorded as error but container still up). + // live_error is non-null only when the most recent inspect failed + // (socket unreachable, 5xx, parse failure); the loop preserves the last + // successful live_status / live_exit_code in that case so a transient + // daemon glitch doesn't rewrite truth. + { + version: 9, + up(db) { + addColumnIfMissing( + db, + "projects", + "live_status", + "ALTER TABLE projects ADD COLUMN live_status TEXT", + ); + }, + }, + { + version: 10, + up(db) { + addColumnIfMissing( + db, + "projects", + "live_exit_code", + "ALTER TABLE projects ADD COLUMN live_exit_code INTEGER", + ); + }, + }, + { + version: 11, + up(db) { + addColumnIfMissing( + db, + "projects", + "live_checked_at", + "ALTER TABLE projects ADD COLUMN live_checked_at TEXT", + ); + }, + }, + { + version: 12, + up(db) { + addColumnIfMissing( + db, + "projects", + "live_error", + "ALTER TABLE projects ADD COLUMN live_error TEXT", + ); + }, + }, + + // #65: live build observability. runs now represents the full deploy run + // (build/pull + container start) and is INSERTed at start with finished_at + // NULL, then UPDATEd as output streams in. Status uses the existing + // finished_at IS NULL convention (no new state column — that would force + // a coordinated web/MCP rollout). The new *_total_bytes columns capture + // the truth Docker emitted, since stdout/stderr now store at most a + // 64 KiB tail (TAIL_CAP_BYTES) for builds. Backfill: existing rows store + // full output, so total_bytes == length(stored). + { + version: 13, + up(db) { + addColumnIfMissing( + db, + "runs", + "started_at_ms", + "ALTER TABLE runs ADD COLUMN started_at_ms INTEGER", + ); + db.exec( + "UPDATE runs SET started_at_ms = CAST(strftime('%s', started_at) AS INTEGER) * 1000 WHERE started_at_ms IS NULL AND started_at IS NOT NULL", + ); + }, + }, + { + version: 14, + up(db) { + addColumnIfMissing( + db, + "runs", + "finished_at_ms", + "ALTER TABLE runs ADD COLUMN finished_at_ms INTEGER", + ); + db.exec( + "UPDATE runs SET finished_at_ms = CAST(strftime('%s', finished_at) AS INTEGER) * 1000 WHERE finished_at_ms IS NULL AND finished_at IS NOT NULL", + ); + }, + }, + { + version: 15, + up(db) { + addColumnIfMissing( + db, + "runs", + "stdout_total_bytes", + "ALTER TABLE runs ADD COLUMN stdout_total_bytes INTEGER", + ); + db.exec( + "UPDATE runs SET stdout_total_bytes = length(CAST(stdout AS BLOB)) WHERE stdout_total_bytes IS NULL AND stdout IS NOT NULL", + ); + }, + }, + { + version: 16, + up(db) { + addColumnIfMissing( + db, + "runs", + "stderr_total_bytes", + "ALTER TABLE runs ADD COLUMN stderr_total_bytes INTEGER", + ); + db.exec( + "UPDATE runs SET stderr_total_bytes = length(CAST(stderr AS BLOB)) WHERE stderr_total_bytes IS NULL AND stderr IS NOT NULL", + ); + }, + }, + + // #111: projects opt into a stored source credential. NULL = today's path + // (anonymous public clone, or legacy URL-embedded credentials in + // github_url). When set, the build path resolves the credential and + // uses it for Docker's daemon-side `remote=` build (#112 wires the + // in-memory URL synthesis). FK uses ON DELETE RESTRICT semantics via + // the route layer (deleteCredential refuses when projects reference + // it); SQLite's RESTRICT is the default for REFERENCES. + { + version: 17, + up(db) { + addColumnIfMissing( + db, + "projects", + "source_credential_id", + "ALTER TABLE projects ADD COLUMN source_credential_id INTEGER REFERENCES source_credentials(id)", + ); + }, + }, + + // Declarative container command/entrypoint override. NULL = today's behavior + // (run the image's own default CMD/ENTRYPOINT). When set, stored as a JSON + // string array and threaded into the Docker create body as Cmd / Entrypoint on + // the next container recreate. Lets a stock image (e.g. cloudflare/cloudflared) + // run a custom command without a throwaway Dockerfile. + { + version: 18, + up(db) { + addColumnIfMissing(db, "projects", "command", "ALTER TABLE projects ADD COLUMN command TEXT"); + }, + }, + { + version: 19, + up(db) { + addColumnIfMissing( + db, + "projects", + "entrypoint", + "ALTER TABLE projects ADD COLUMN entrypoint TEXT", + ); + }, + }, +]; + +export const finalSchemaVersion = schemaMigrations[schemaMigrations.length - 1]?.version ?? 0; + +export function runMigrations( + db: Database, + migrations: readonly Migration[] = schemaMigrations, +): void { + let currentVersion = getUserVersion(db); + let previousMigrationVersion = 0; + const runMigration = db.transaction((migration: Migration) => { + migration.up(db); + setUserVersion(db, migration.version); + }); + + for (const migration of migrations) { + if (!Number.isInteger(migration.version) || migration.version <= 0) { + throw new Error(`Invalid migration version: ${migration.version}`); + } + if (migration.version <= previousMigrationVersion) { + throw new Error( + `Migrations must be ordered by increasing version: ${migration.version} after ${previousMigrationVersion}`, + ); + } + previousMigrationVersion = migration.version; + + if (migration.version <= currentVersion) { + continue; + } + + runMigration(migration); + currentVersion = migration.version; + } +} diff --git a/apps/api/db.ts b/apps/api/db.ts index 51de320..014ec2f 100644 --- a/apps/api/db.ts +++ b/apps/api/db.ts @@ -1,5 +1,6 @@ import { Database } from "bun:sqlite"; import { join } from "node:path"; +import { runMigrations } from "./db-migrations"; // Tests set MOOR_DB_PATH=":memory:" to run against a transient SQLite DB without // touching the dev/prod file. Default path is unchanged for normal startup. @@ -274,149 +275,11 @@ db.exec(` WHERE state = 'running' `); -// Migrations — add columns that may not exist in older databases -try { - db.exec("ALTER TABLE projects ADD COLUMN docker_image TEXT"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN domain TEXT"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN domain_port INTEGER"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN restart_policy TEXT DEFAULT 'unless-stopped'"); -} catch { - // Column already exists -} - -// #45: millisecond timestamps for exec_runs. New rows write Date.now() from -// JS in exec-async.ts. Old rows are backfilled best-effort from the text -// columns, which are SQLite second-precision so the backfilled values are -// snapped to the start of their wall-clock second (good enough for runs that -// pre-date this migration). New rows get true millisecond precision. -try { - db.exec("ALTER TABLE exec_runs ADD COLUMN started_at_ms INTEGER"); - db.exec( - "UPDATE exec_runs SET started_at_ms = CAST(strftime('%s', started_at) AS INTEGER) * 1000 WHERE started_at_ms IS NULL AND started_at IS NOT NULL", - ); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE exec_runs ADD COLUMN finished_at_ms INTEGER"); - db.exec( - "UPDATE exec_runs SET finished_at_ms = CAST(strftime('%s', finished_at) AS INTEGER) * 1000 WHERE finished_at_ms IS NULL AND finished_at IS NOT NULL", - ); -} catch { - // Column already exists -} - -// #36: per-project memory and CPU limits. NULL = unbounded (current behavior, -// no Docker HostConfig fields set). When set: memory_limit_mb maps to Memory -// (and equal MemorySwap so the container can't burn through host swap) and -// cpus maps to NanoCpus (cpus * 1e9). Limits take effect on container -// recreate — handleStart/handleRun all call createAndStartContainer which -// force-removes the existing container by name and creates fresh. -try { - db.exec("ALTER TABLE projects ADD COLUMN memory_limit_mb INTEGER"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN cpus REAL"); -} catch { - // Column already exists -} - -// #71: dual-field model for runtime truth. projects.status stays moor's -// *recorded* state (changes only on explicit moor actions: start/stop/ -// build/cancel). The live_* fields are written by the status reconciler -// background loop and reflect Docker's view at last successful inspect. -// Both directions matter — DB can drift from Docker (missed exit) and -// Docker can drift from DB (recorded as error but container still up). -// live_error is non-null only when the most recent inspect failed -// (socket unreachable, 5xx, parse failure); the loop preserves the last -// successful live_status / live_exit_code in that case so a transient -// daemon glitch doesn't rewrite truth. -try { - db.exec("ALTER TABLE projects ADD COLUMN live_status TEXT"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN live_exit_code INTEGER"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN live_checked_at TEXT"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN live_error TEXT"); -} catch { - // Column already exists -} - -// #65: live build observability. runs now represents the full deploy run -// (build/pull + container start) and is INSERTed at start with finished_at -// NULL, then UPDATEd as output streams in. Status uses the existing -// finished_at IS NULL convention (no new state column — that would force -// a coordinated web/MCP rollout). The new *_total_bytes columns capture -// the truth Docker emitted, since stdout/stderr now store at most a -// 64 KiB tail (TAIL_CAP_BYTES) for builds. Backfill: existing rows store -// full output, so total_bytes == length(stored). -try { - db.exec("ALTER TABLE runs ADD COLUMN started_at_ms INTEGER"); - db.exec( - "UPDATE runs SET started_at_ms = CAST(strftime('%s', started_at) AS INTEGER) * 1000 WHERE started_at_ms IS NULL AND started_at IS NOT NULL", - ); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE runs ADD COLUMN finished_at_ms INTEGER"); - db.exec( - "UPDATE runs SET finished_at_ms = CAST(strftime('%s', finished_at) AS INTEGER) * 1000 WHERE finished_at_ms IS NULL AND finished_at IS NOT NULL", - ); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE runs ADD COLUMN stdout_total_bytes INTEGER"); - db.exec( - "UPDATE runs SET stdout_total_bytes = length(CAST(stdout AS BLOB)) WHERE stdout_total_bytes IS NULL AND stdout IS NOT NULL", - ); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE runs ADD COLUMN stderr_total_bytes INTEGER"); - db.exec( - "UPDATE runs SET stderr_total_bytes = length(CAST(stderr AS BLOB)) WHERE stderr_total_bytes IS NULL AND stderr IS NOT NULL", - ); -} catch { - // Column already exists -} +// Schema migrations are versioned from the old pre-runner state. Existing +// pre-versioning DBs report user_version=0 even when the former blind ALTER +// blocks already added every column; each migration checks PRAGMA table_info +// before ADD COLUMN, skips existing columns, and still records its version. +runMigrations(db); // #65 orphan sweep for build/manual runs. cron_id IS NULL && finished_at // IS NULL means a build was in flight when moor crashed/restarted — the @@ -449,38 +312,6 @@ db.exec(` WHERE finished_at IS NULL AND cron_id IS NULL `); -// #111: projects opt into a stored source credential. NULL = today's path -// (anonymous public clone, or legacy URL-embedded credentials in -// github_url). When set, the build path resolves the credential and -// uses it for Docker's daemon-side `remote=` build (#112 wires the -// in-memory URL synthesis). FK uses ON DELETE RESTRICT semantics via -// the route layer (deleteCredential refuses when projects reference -// it); SQLite's RESTRICT is the default for REFERENCES. -try { - db.exec( - "ALTER TABLE projects ADD COLUMN source_credential_id INTEGER REFERENCES source_credentials(id)", - ); -} catch { - // Column already exists -} - -// Declarative container command/entrypoint override. NULL = today's behavior -// (run the image's own default CMD/ENTRYPOINT). When set, stored as a JSON -// string array and threaded into the Docker create body as Cmd / Entrypoint on -// the next container recreate. Lets a stock image (e.g. cloudflare/cloudflared) -// run a custom command without a throwaway Dockerfile. -try { - db.exec("ALTER TABLE projects ADD COLUMN command TEXT"); -} catch { - // Column already exists -} - -try { - db.exec("ALTER TABLE projects ADD COLUMN entrypoint TEXT"); -} catch { - // Column already exists -} - // Project observability history. Three additive tables, all keyed on the // project so "what happened to this project around this case?" can be answered // from stored evidence rather than a live snapshot. diff --git a/apps/api/deploy.test.ts b/apps/api/deploy.test.ts new file mode 100644 index 0000000..bdf0a1c --- /dev/null +++ b/apps/api/deploy.test.ts @@ -0,0 +1,311 @@ +process.env.MOOR_DB_PATH = ":memory:"; + +import { describe, expect, test } from "bun:test"; +import type { BuildRunLike, DeployDeps, Project, ProjectActionResult } from "./deploy"; + +const { buildProject, deployProject, startProject } = await import("./deploy"); + +function makeProject(overrides: Partial = {}): Project { + return { + id: 1, + name: "app", + github_url: "https://github.com/owner/repo", + docker_image: null, + branch: "main", + dockerfile: "Dockerfile", + image_tag: null, + container_id: null, + status: "stopped", + domain: null, + domain_port: null, + restart_policy: "unless-stopped", + memory_limit_mb: null, + cpus: null, + source_credential_id: null, + command: null, + entrypoint: null, + ...overrides, + }; +} + +function makeRun(ops: string[]): BuildRunLike { + return { + abort: new AbortController(), + appendStdout: (text) => ops.push(`stdout:${text}`), + appendStderr: (text) => ops.push(`stderr:${text}`), + markStreamingDone: () => ops.push("run:streamingDone"), + finalize: (exitCode) => ops.push(`run:finalize:${exitCode}`), + }; +} + +function makeDeps(ops: string[], overrides: Partial = {}): DeployDeps { + let now = 1_000; + const base: DeployDeps = { + requireNotDraining: () => { + ops.push("drain"); + return null; + }, + resolveCredentialForBuild: (githubUrl, sourceCredentialId) => { + ops.push(`resolve:${githubUrl}:${sourceCredentialId ?? "none"}`); + return { + ok: true, + value: { + cloneUrl: "https://github.com/owner/repo.git", + used_credential_id: sourceCredentialId ?? null, + }, + }; + }, + setProjectRecordedStatus: (_projectId, status, containerId) => { + ops.push(`status:${status}:${containerId ?? "null"}`); + }, + reconcileProjectStatusAfterInterrupt: async (_projectId, containerId) => { + ops.push(`reconcile:${containerId ?? "null"}`); + return "stopped"; + }, + createBuildRun: (projectId) => { + ops.push(`run:create:${projectId}`); + return makeRun(ops); + }, + buildImageStreaming: async (cloneUrl, branch, dockerfile, tag, onLine, noCache, signal) => { + ops.push( + `build:${cloneUrl}:${branch}:${dockerfile}:${tag}:nocache=${noCache}:aborted=${signal?.aborted ?? false}`, + ); + onLine("build line\n"); + }, + pullImageStreaming: async (imageRef, onLine, signal) => { + ops.push(`pull:${imageRef}:aborted=${signal?.aborted ?? false}`); + onLine("pull line\n"); + }, + autoDetectPorts: async (projectId, imageTag, force) => { + ops.push(`detectPorts:${projectId}:${imageTag}:${force === true}`); + return [{ host_port: 18080, container_port: 8080 }]; + }, + listEnvVars: (projectId) => { + ops.push(`envs:${projectId}`); + return [{ key: "NODE_ENV", value: "production" }]; + }, + getProjectPorts: (projectId) => { + ops.push(`ports:${projectId}`); + return [{ host_port: 18080, container_port: 8080 }]; + }, + getProjectVolumes: (projectId) => { + ops.push(`volumes:${projectId}`); + return [{ docker_name: "app-data", target: "/data" }]; + }, + getResolvedProjectFiles: (projectId, envs) => { + ops.push(`files:${projectId}:${envs.length}`); + return []; + }, + createAndStartContainer: async ( + imageTag, + name, + envs, + ports = [], + restartPolicy = "unless-stopped", + limits = {}, + volumes = [], + _labels = {}, + extras = {}, + ) => { + ops.push( + `create:${imageTag}:${name}:envs=${envs.length}:ports=${ports.length}:restart=${restartPolicy}:mem=${limits.memoryLimitMb ?? "null"}:cpus=${limits.cpus ?? "null"}:volumes=${volumes.length}:cmd=${extras.command?.length ?? 0}:entrypoint=${extras.entrypoint?.length ?? 0}:files=${extras.files?.length ?? 0}`, + ); + return "container-1"; + }, + stopContainer: async (containerId) => { + ops.push(`stop:${containerId}`); + }, + syncCaddyRoutes: async () => { + ops.push("caddy"); + }, + updateProjectImageTag: (projectId, imageTag) => { + ops.push(`image:${projectId}:${imageTag}`); + }, + updateProjectContainerId: (projectId, containerId) => { + ops.push(`container:${projectId}:${containerId}`); + }, + now: () => { + const current = now; + now += 1_500; + return current; + }, + }; + + return { ...base, ...overrides }; +} + +async function readStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; +} + +async function expectErrorResult( + result: ProjectActionResult, + status: number, + error: string, +): Promise { + expect(result.kind).toBe("response"); + if (result.kind !== "response") return; + expect(result.response.status).toBe(status); + expect(result.response.headers.get("content-type") || "").toContain("application/json"); + expect(await result.response.json()).toEqual({ error }); +} + +describe("deployProject orchestration", () => { + test("builds, records, detects ports, starts container, syncs routes, and finalizes success", async () => { + const ops: string[] = []; + const project = makeProject({ domain: "app.example.com", domain_port: 8080 }); + const result = await deployProject(project, { noCache: true }, makeDeps(ops)); + + expect(result.kind).toBe("stream"); + if (result.kind !== "stream") return; + const sse = await readStream(result.stream); + + expect(ops).toEqual([ + "drain", + "resolve:https://github.com/owner/repo:none", + "status:building:null", + "run:create:1", + "build:https://github.com/owner/repo.git:main:Dockerfile:moor/app:latest:nocache=true:aborted=false", + "stdout:build line\n", + "run:streamingDone", + "image:1:moor/app:latest", + "status:stopped:null", + "stdout:\nBuild completed in 1.5s\n", + "detectPorts:1:moor/app:latest:true", + "stdout:Port 8080 → host :18080\n", + "stdout:Starting container...\n", + "envs:1", + "ports:1", + "volumes:1", + "files:1:1", + "create:moor/app:latest:moor-app:envs=1:ports=1:restart=unless-stopped:mem=null:cpus=null:volumes=1:cmd=0:entrypoint=0:files=0", + "container:1:container-1", + "status:running:container-1", + "caddy", + "stdout:Route: app.example.com -> :8080\n", + "run:finalize:0", + ]); + expect(sse).toContain('event: log\ndata: "build line\\n"'); + expect(sse).toContain('event: log\ndata: "Starting container...\\n"'); + expect(sse).toContain('event: done\ndata: "Container started"'); + }); + + test("finalizes build failure without image update or container start", async () => { + const ops: string[] = []; + const deps = makeDeps(ops, { + buildImageStreaming: async () => { + ops.push("build:throw"); + throw new Error("Docker build failed"); + }, + }); + const result = await deployProject(makeProject(), { noCache: false }, deps); + + expect(result.kind).toBe("stream"); + if (result.kind !== "stream") return; + const sse = await readStream(result.stream); + + expect(ops).toEqual([ + "drain", + "resolve:https://github.com/owner/repo:none", + "status:building:null", + "run:create:1", + "build:throw", + "stderr:Docker build failed\n", + "run:finalize:1", + "status:error:null", + ]); + expect(sse).toContain('event: error\ndata: "Docker build failed"'); + expect(ops.some((op) => op.startsWith("image:"))).toBe(false); + expect(ops.some((op) => op.startsWith("create:"))).toBe(false); + }); + + test("drain rejection returns before resolving credentials or creating a run", async () => { + const ops: string[] = []; + const deps = makeDeps(ops, { + requireNotDraining: () => { + ops.push("drain"); + return Response.json({ error: "moor is draining" }, { status: 503 }); + }, + }); + const result = await deployProject(makeProject(), { noCache: false }, deps); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") return; + expect(result.response.status).toBe(503); + expect(await result.response.json()).toEqual({ error: "moor is draining" }); + expect(ops).toEqual(["drain"]); + }); + + test("no source configured returns a JSON error response", async () => { + const ops: string[] = []; + const result = await deployProject( + makeProject({ github_url: null, docker_image: null, image_tag: null }), + { noCache: false }, + makeDeps(ops), + ); + + await expectErrorResult(result, 400, "No GitHub URL or Docker image configured"); + expect(ops).toEqual(["drain"]); + }); + + test("strict GitHub URL validation rejects unsupported hosts before side effects", async () => { + const ops: string[] = []; + const result = await deployProject( + makeProject({ github_url: "https://gist.github.com/owner/repo" }), + { noCache: false }, + makeDeps(ops), + ); + + await expectErrorResult(result, 400, "Only GitHub URLs are supported"); + expect(ops).toEqual(["drain"]); + }); + + test("build validation errors use the JSON error envelope", async () => { + const ops: string[] = []; + const result = await buildProject(makeProject({ github_url: null }), makeDeps(ops)); + + await expectErrorResult(result, 400, "No GitHub URL configured"); + expect(ops).toEqual(["drain"]); + }); + + test("start validation errors use the JSON error envelope", async () => { + const ops: string[] = []; + const result = await startProject(makeProject({ image_tag: null }), makeDeps(ops)); + + await expectErrorResult(result, 400, "No image built yet"); + expect(ops).toEqual(["drain"]); + }); + + test("container start failures use the JSON error envelope", async () => { + const ops: string[] = []; + const deps = makeDeps(ops, { + createAndStartContainer: async () => { + ops.push("create:throw"); + throw new Error("container failed"); + }, + }); + const result = await startProject(makeProject({ image_tag: "moor/app:latest" }), deps); + + await expectErrorResult(result, 500, "container failed"); + expect(ops).toEqual([ + "drain", + "envs:1", + "ports:1", + "volumes:1", + "files:1:1", + "create:throw", + "status:error:null", + ]); + }); +}); diff --git a/apps/api/deploy.ts b/apps/api/deploy.ts new file mode 100644 index 0000000..3c75c92 --- /dev/null +++ b/apps/api/deploy.ts @@ -0,0 +1,604 @@ +import { classifyBuildError } from "./build-error-classifier"; +import { BuildRun } from "./build-runs"; +import { syncCaddyRoutes } from "./caddy"; +import { parseStringArray, type ResolvedFile } from "./container-config"; +import db from "./db"; +import { + buildImageStreaming, + createAndStartContainer, + projectLabels, + pullImageStreaming, + stopContainer, +} from "./docker"; +import { requireNotDraining } from "./drain"; +import { validateGithubUrl } from "./github-url"; +import { errorResponse } from "./http"; +import { autoDetectPorts, getProjectPorts } from "./ports"; +import { redactCredentials, redactCredentialsInText } from "./redact"; +import { getResolvedProjectFiles } from "./routes/files"; +import { getProjectVolumes } from "./routes/volumes"; +import { type ResolveFailure, resolveCredentialForBuild } from "./source-credential-resolver"; +import { + reconcileProjectStatusAfterInterrupt, + setProjectRecordedStatus, +} from "./status-reconciler"; + +export type Project = { + id: number; + name: string; + github_url: string | null; + docker_image: string | null; + branch: string; + dockerfile: string; + image_tag: string | null; + container_id: string | null; + status: string; + domain: string | null; + domain_port: number | null; + restart_policy: string; + memory_limit_mb: number | null; + cpus: number | null; + source_credential_id: number | null; + // JSON-encoded string arrays (or null). Parsed via parseStringArray before + // they reach the Docker create body as Cmd / Entrypoint. + command: string | null; + entrypoint: string | null; +}; + +type EnvVar = { key: string; value: string }; +type PortBinding = { host_port: number; container_port: number }; +type VolumeMount = { docker_name: string; target: string }; + +type ContainerStartConfig = { + envs: EnvVar[]; + ports: PortBinding[]; + volumes: VolumeMount[]; + labels: Record; + extras: { + command: string[] | null; + entrypoint: string[] | null; + files: ResolvedFile[]; + }; +}; + +export type ProjectActionResult = + | { kind: "response"; response: Response } + | { kind: "json"; body: unknown; status?: number } + | { kind: "stream"; stream: ReadableStream }; + +export type BuildRunLike = { + readonly abort: AbortController; + appendStdout(text: string): void; + appendStderr(text: string): void; + markStreamingDone(): void; + finalize(exitCode: number): void; +}; + +export type DeployDeps = { + requireNotDraining: () => Response | null; + resolveCredentialForBuild: typeof resolveCredentialForBuild; + setProjectRecordedStatus: typeof setProjectRecordedStatus; + reconcileProjectStatusAfterInterrupt: typeof reconcileProjectStatusAfterInterrupt; + createBuildRun: (projectId: number) => BuildRunLike; + buildImageStreaming: typeof buildImageStreaming; + pullImageStreaming: typeof pullImageStreaming; + autoDetectPorts: (projectId: number, imageTag: string, force?: boolean) => Promise; + listEnvVars: (projectId: number) => EnvVar[]; + getProjectPorts: (projectId: number) => PortBinding[]; + getProjectVolumes: (projectId: number) => VolumeMount[]; + getResolvedProjectFiles: (projectId: number, envs: EnvVar[]) => ResolvedFile[]; + createAndStartContainer: typeof createAndStartContainer; + stopContainer: typeof stopContainer; + syncCaddyRoutes: typeof syncCaddyRoutes; + updateProjectImageTag: (projectId: number, imageTag: string) => void; + updateProjectContainerId: (projectId: number, containerId: string) => void; + now: () => number; +}; + +function makeDefaultDeps(): DeployDeps { + return { + requireNotDraining, + resolveCredentialForBuild, + setProjectRecordedStatus, + reconcileProjectStatusAfterInterrupt, + createBuildRun: (projectId) => new BuildRun(projectId), + buildImageStreaming, + pullImageStreaming, + autoDetectPorts, + listEnvVars: (projectId) => + db.query("SELECT key, value FROM env_vars WHERE project_id = ?").all(projectId) as EnvVar[], + getProjectPorts, + getProjectVolumes, + getResolvedProjectFiles, + createAndStartContainer, + stopContainer, + syncCaddyRoutes, + updateProjectImageTag: (projectId, imageTag) => { + db.query("UPDATE projects SET image_tag = ? WHERE id = ?").run(imageTag, projectId); + }, + updateProjectContainerId: (projectId, containerId) => { + db.query("UPDATE projects SET container_id = ? WHERE id = ?").run(containerId, projectId); + }, + now: () => Date.now(), + }; +} + +function makeDeployDeps(partialDeps?: Partial): DeployDeps { + return { ...makeDefaultDeps(), ...partialDeps }; +} + +function resolverFailureResult(failure: ResolveFailure): ProjectActionResult { + return { kind: "json", body: failure, status: 400 }; +} + +function errorResult(message: string, status: number): ProjectActionResult { + return { kind: "response", response: errorResponse(message, status) }; +} + +/** Build the /build catch-path response from an already-redacted error + * message. Classified auth failures (#119) become 401 JSON with a + * structured code so agents can branch; unclassified errors use the + * standard JSON error envelope. */ +export function buildErrorResponse(message: string): Response { + const code = classifyBuildError(message); + if (code !== "unknown") { + return Response.json({ code, message }, { status: 401 }); + } + return errorResponse(message, 500); +} + +/** SSE events to emit on a /run catch-path failure from an already- + * redacted error message. structured-error fires first when the failure + * classifies (#119) so a parsing agent can branch on the code; the + * trailing legacy event: error keeps existing UI/CLI/MCP consumers + * working unchanged. */ +export function buildErrorEvents( + message: string, +): Array< + | { event: "structured-error"; data: { code: string; message: string } } + | { event: "error"; data: string } +> { + const code = classifyBuildError(message); + const events: ReturnType = []; + if (code !== "unknown") { + events.push({ event: "structured-error", data: { code, message } }); + } + events.push({ event: "error", data: message }); + return events; +} + +function buildErrorResult(message: string): ProjectActionResult { + const code = classifyBuildError(message); + if (code !== "unknown") { + return { kind: "json", body: { code, message }, status: 401 }; + } + return errorResult(message, 500); +} + +function buildContainerStartConfig( + project: Project, + deps: DeployDeps, + envs = deps.listEnvVars(project.id), + ports = deps.getProjectPorts(project.id), +): ContainerStartConfig { + return { + envs, + ports, + volumes: deps.getProjectVolumes(project.id), + labels: projectLabels(project.id, project.name), + extras: { + command: parseStringArray(project.command), + entrypoint: parseStringArray(project.entrypoint), + files: deps.getResolvedProjectFiles(project.id, envs), + }, + }; +} + +async function createStartAndRecord( + project: Project, + imageTag: string, + deps: DeployDeps, + config = buildContainerStartConfig(project, deps), +): Promise { + const containerId = await deps.createAndStartContainer( + imageTag, + `moor-${project.name}`, + config.envs, + config.ports, + project.restart_policy, + { memoryLimitMb: project.memory_limit_mb, cpus: project.cpus }, + config.volumes, + config.labels, + config.extras, + ); + deps.updateProjectContainerId(project.id, containerId); + deps.setProjectRecordedStatus(project.id, "running", containerId); + + if (project.domain) { + await deps.syncCaddyRoutes(); + } + + return containerId; +} + +export async function deployProject( + project: Project, + input: { noCache: boolean }, + partialDeps?: Partial, +): Promise { + const deps = makeDeployDeps(partialDeps); + + // #79: drain-mode gate. Cheapest check first - refuse new deploys + // before parsing URL or touching Docker. startProject fallback below + // also goes through its own gate. + const drained = deps.requireNotDraining(); + if (drained) return { kind: "response", response: drained }; + + const noCache = input.noCache; + const dockerImage = project.docker_image; + const isImageProject = !!dockerImage; + console.log( + `[run] starting run for project ${project.name} (id=${project.id}) type=${isImageProject ? "image" : "github"} nocache=${noCache}`, + ); + + if (!project.github_url && !project.docker_image) { + if (project.image_tag) { + console.log("[run] no source, starting existing image"); + return startProject(project, deps); + } + console.log("[run] no source or image_tag — nothing to do"); + return errorResult("No GitHub URL or Docker image configured", 400); + } + + if (project.github_url) { + const urlError = validateGithubUrl(project.github_url); + if (urlError) return errorResult(urlError, 400); + } + + // Resolve source credential BEFORE any side effects (status flip, + // BuildRun row, SSE stream open). A resolver failure should look like + // a validation error to the caller, not a half-built build run. + // Image projects skip resolution; only github_url builds consume creds. + let resolvedCloneUrl: string | null = null; + let resolvedCredentialId: number | null = null; + if (!isImageProject && project.github_url) { + const resolved = deps.resolveCredentialForBuild( + project.github_url, + project.source_credential_id ?? undefined, + ); + if (!resolved.ok) { + return resolverFailureResult(resolved); + } + resolvedCloneUrl = resolved.value.cloneUrl; + resolvedCredentialId = resolved.value.used_credential_id; + } + + const tag = dockerImage || `moor/${project.name}:latest`; + const status = isImageProject ? "pulling" : "building"; + console.log( + `[run] image tag will be: ${tag}` + + (resolvedCredentialId !== null + ? ` source_credential_id=${resolvedCredentialId}` + : !isImageProject + ? " anonymous-clone" + : ""), + ); + deps.setProjectRecordedStatus(project.id, status, project.container_id); + + // #65: one deploy run row covers build/pull + port detection + container + // start. INSERT before the build starts so moor_run_get can tail mid-build; + // BuildRun periodically flushes the rolling tail into runs.stdout. + const run = deps.createBuildRun(project.id); + + // Stream build/pull output via SSE + let streamClosed = false; + let keepalive: ReturnType | null = null; + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + // data is JSON-stringified, so strings come through as JSON strings + // and objects (used by event: structured-error in #119) come through + // as JSON objects. Consumers JSON.parse once to get the original. + const send = (event: string, data: unknown) => { + if (streamClosed) return; + try { + controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); + } catch { + streamClosed = true; + } + }; + + // Mirror every log line into both the SSE stream (for the UI/CLI) and + // the persistent BuildRun (for moor_run_get). Single source of text. + const log = (line: string) => { + send("log", line); + run.appendStdout(line); + }; + + const safeClose = () => { + if (keepalive !== null) clearInterval(keepalive); + if (streamClosed) return; + streamClosed = true; + try { + controller.close(); + } catch { + // Already closed + } + }; + + keepalive = setInterval(() => { + if (streamClosed) return; + try { + controller.enqueue(encoder.encode(":keepalive\n\n")); + } catch { + streamClosed = true; + } + }, 5000); + + const startTime = deps.now(); + + try { + if (dockerImage) { + log(`Pulling ${dockerImage}...\n`); + await deps.pullImageStreaming(dockerImage, log, run.abort.signal); + } else { + // resolvedCloneUrl is guaranteed non-null in this branch + // (resolved above, before side effects). + await deps.buildImageStreaming( + resolvedCloneUrl as string, + project.branch, + project.dockerfile, + tag, + log, + noCache, + run.abort.signal, + ); + } + + const elapsed = ((deps.now() - startTime) / 1000).toFixed(1); + const verb = isImageProject ? "Pull" : "Build"; + + // #68: past this point cancel() can't stop anything useful - the + // container-start phase below uses different Docker endpoints and + // AbortController on the build/pull fetch won't reach them. + run.markStreamingDone(); + + deps.updateProjectImageTag(project.id, tag); + deps.setProjectRecordedStatus(project.id, "stopped", project.container_id); + + log(`\n${verb} completed in ${elapsed}s\n`); + + // Auto-detect exposed ports from image + const detectedPorts = await deps.autoDetectPorts(project.id, tag, true); + for (const { host_port, container_port } of detectedPorts) { + log(`Port ${container_port} → host :${host_port}\n`); + } + } catch (e) { + // #68: if cancel() fired AbortError, BuildRun.cancel already + // finalized the row with exit_code=130 and "[cancelled by user]". + // Don't re-finalize or overwrite with a generic failure. Also + // reconcile status from the actual container state - the cancel + // didn't touch the previously-running container, so leaving + // status='error' would lie about the project state. + if (run.abort.signal.aborted) { + await deps.reconcileProjectStatusAfterInterrupt(project.id, project.container_id); + send("error", "cancelled by user"); + safeClose(); + return; + } + const rawMessage = e instanceof Error ? e.message : "Unknown error"; + // Redact any credentialed URLs Docker may have echoed into the + // error message before it lands in logs, stored stderr, or SSE. + const message = redactCredentialsInText(rawMessage); + console.error(`[run] FAILED: ${message}`); + run.appendStderr(`${message}\n`); + run.finalize(1); + deps.setProjectRecordedStatus(project.id, "error", project.container_id); + for (const ev of buildErrorEvents(message)) send(ev.event, ev.data); + safeClose(); + return; + } + + // Container start is part of the same deploy run - operator's + // mental model is "rebuild" includes "and is now running." + try { + log("Starting container...\n"); + const containerId = await createStartAndRecord(project, tag, deps); + console.log(`[run] container started: ${containerId}`); + + if (project.domain) { + log(`Route: ${project.domain} -> :${project.domain_port}\n`); + } + + run.finalize(0); + send("done", "Container started"); + } catch (e) { + deps.setProjectRecordedStatus(project.id, "error", project.container_id); + const message = e instanceof Error ? e.message : "Unknown error"; + console.error(`[run] CONTAINER START FAILED: ${message}`); + run.appendStderr(`${message}\n`); + run.finalize(1); + send("error", message); + } + + safeClose(); + }, + cancel() { + if (keepalive !== null) clearInterval(keepalive); + streamClosed = true; + // If the client disconnects mid-build the build still runs to + // completion on the daemon and finalize() will fire from the build + // try/catch above. No need to finalize here. + }, + }); + + return { kind: "stream", stream }; +} + +export async function buildProject( + project: Project, + partialDeps?: Partial, +): Promise { + const deps = makeDeployDeps(partialDeps); + + // #79: drain-mode gate. Builds are explicitly listed in the drain + // refusal scope - they're long-running work that an upgrade can't + // safely interleave with. + const drained = deps.requireNotDraining(); + if (drained) return { kind: "response", response: drained }; + + console.log( + `[build] project=${project.name} github_url=${redactCredentials(project.github_url) ?? ""}`, + ); + if (!project.github_url) { + console.log("[build] rejected — no github_url"); + return errorResult("No GitHub URL configured", 400); + } + const urlError = validateGithubUrl(project.github_url); + if (urlError) return errorResult(urlError, 400); + + // Resolve source credential BEFORE any side effects (status flip, + // BuildRun row). Same contract as /run. + const resolved = deps.resolveCredentialForBuild( + project.github_url, + project.source_credential_id ?? undefined, + ); + if (!resolved.ok) { + return resolverFailureResult(resolved); + } + const { cloneUrl, used_credential_id } = resolved.value; + + const tag = `moor/${project.name}:latest`; + console.log( + `[build] tag=${tag} branch=${project.branch} dockerfile=${project.dockerfile} ` + + (used_credential_id !== null + ? `source_credential_id=${used_credential_id}` + : "anonymous-clone"), + ); + deps.setProjectRecordedStatus(project.id, "building", project.container_id); + + // /build is the legacy non-SSE path used by api.projects.build in the web + // wrapper. We still wire it through BuildRun + buildImageStreaming so the + // row shape (started_at_ms, totals, exit_code, orphan-sweep eligibility) + // matches /run and moor_run_get can tail it mid-build. Returns when the + // build finishes, like the old contract. + const run = deps.createBuildRun(project.id); + + try { + console.log("[build] starting docker build..."); + await deps.buildImageStreaming( + cloneUrl, + project.branch, + project.dockerfile, + tag, + (line) => run.appendStdout(line), + false, + run.abort.signal, + ); + run.markStreamingDone(); + deps.updateProjectImageTag(project.id, tag); + deps.setProjectRecordedStatus(project.id, "stopped", project.container_id); + + // Auto-detect exposed ports from image (always re-detect on rebuild) + await deps.autoDetectPorts(project.id, tag, true); + + run.finalize(0); + console.log("[build] done — status set to 'stopped'"); + return { kind: "json", body: { message: "Build complete" } }; + } catch (e) { + // #68: cancel already finalized as exit 130 with "[cancelled by user]"; + // don't overwrite with a generic failure. Reconcile status from the + // actual container state - the cancel didn't touch a running container. + if (run.abort.signal.aborted) { + await deps.reconcileProjectStatusAfterInterrupt(project.id, project.container_id); + return errorResult("cancelled by user", 499); + } + deps.setProjectRecordedStatus(project.id, "error", project.container_id); + const rawMessage = e instanceof Error ? e.message : "Unknown error"; + const message = redactCredentialsInText(rawMessage); + console.error(`[build] FAILED: ${message}`); + run.appendStderr(`${message}\n`); + run.finalize(1); + return buildErrorResult(message); + } +} + +export async function startProject( + project: Project, + partialDeps?: Partial, +): Promise { + const deps = makeDeployDeps(partialDeps); + + // #79: drain-mode gate. Starting a container is "new work" from + // moor's perspective - same gate as deploy/build. Stop/logs stay + // open so operators can quiesce things during drain. + const drained = deps.requireNotDraining(); + if (drained) return { kind: "response", response: drained }; + + console.log(`[start] project=${project.name} image=${project.image_tag}`); + if (!project.image_tag) { + console.log("[start] rejected — no image built"); + return errorResult("No image built yet", 400); + } + + const envs = deps.listEnvVars(project.id); + const ports = deps.getProjectPorts(project.id); + console.log( + `[start] creating container moor-${project.name} with ${envs.length} env vars and ${ports.length} ports`, + ); + + try { + const config = buildContainerStartConfig(project, deps, envs, ports); + const containerId = await deps.createAndStartContainer( + project.image_tag, + `moor-${project.name}`, + config.envs, + config.ports, + project.restart_policy, + { memoryLimitMb: project.memory_limit_mb, cpus: project.cpus }, + config.volumes, + config.labels, + config.extras, + ); + console.log(`[start] container started: ${containerId}`); + deps.updateProjectContainerId(project.id, containerId); + deps.setProjectRecordedStatus(project.id, "running", containerId); + + if (project.domain) { + await deps.syncCaddyRoutes(); + } + + return { kind: "json", body: { message: "Container started" } }; + } catch (e) { + deps.setProjectRecordedStatus(project.id, "error", project.container_id); + const message = e instanceof Error ? e.message : "Unknown error"; + console.error(`[start] FAILED: ${message}`); + return errorResult(message, 500); + } +} + +export async function stopProject( + project: Project, + partialDeps?: Partial, +): Promise { + const deps = makeDeployDeps(partialDeps); + + console.log(`[stop] project=${project.name} container=${project.container_id}`); + if (!project.container_id) { + console.log("[stop] no container — marking as stopped"); + deps.setProjectRecordedStatus(project.id, "stopped", project.container_id); + return { kind: "json", body: { message: "Container stopped" } }; + } + + try { + await deps.stopContainer(project.container_id); + console.log("[stop] container stopped"); + } catch (e) { + const message = e instanceof Error ? e.message : "Unknown error"; + console.error(`[stop] error during stop (marking as stopped anyway): ${message}`); + } + + deps.setProjectRecordedStatus(project.id, "stopped", project.container_id); + return { kind: "json", body: { message: "Container stopped" } }; +} diff --git a/apps/api/docker.test.ts b/apps/api/docker.test.ts new file mode 100644 index 0000000..815b64c --- /dev/null +++ b/apps/api/docker.test.ts @@ -0,0 +1,484 @@ +process.env.MOOR_DB_PATH = ":memory:"; +process.env.HOSTNAME = ""; + +import { beforeEach, describe, expect, test } from "bun:test"; +import type { ResolvedFile } from "./container-config"; +import type { DockerFetch } from "./docker"; + +const { + buildContainerCreateBody, + buildImageStreaming, + createAndStartContainer, + pullImageStreaming, +} = await import("./docker"); +const { classifyBuildError } = await import("./build-error-classifier"); +const { default: db } = await import("./db"); +const { createCredential } = await import("./registry-credentials-db"); + +type DockerCall = { + path: string; + method: string; + headers?: HeadersInit; + body?: BodyInit | null; + timeout?: number; +}; + +function streamResponse(chunks: string[], status = 200): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + return new Response(stream, { status }); +} + +function recordCall(calls: DockerCall[], path: string, opts?: RequestInit & { timeout?: number }) { + calls.push({ + path, + method: opts?.method ?? "GET", + headers: opts?.headers, + body: opts?.body, + timeout: opts?.timeout, + }); +} + +function parseJsonBody(body: BodyInit | null | undefined): T { + if (typeof body !== "string") { + throw new Error("expected JSON string body"); + } + return JSON.parse(body) as T; +} + +function headerValue(headers: HeadersInit | undefined, name: string): string | null { + if (!headers) return null; + if (headers instanceof Headers) return headers.get(name); + const lower = name.toLowerCase(); + if (Array.isArray(headers)) { + return headers.find(([key]) => key.toLowerCase() === lower)?.[1] ?? null; + } + const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lower); + return entry?.[1] ?? null; +} + +function tarField(buf: Uint8Array, offset: number, len: number): string { + let end = offset; + while (end < offset + len && buf[end] !== 0) end++; + return new TextDecoder().decode(buf.slice(offset, end)); +} + +function tarContent(buf: Uint8Array, len: number): string { + return new TextDecoder().decode(buf.slice(512, 512 + len)); +} + +function dockerFetchForCreate(calls: DockerCall[], id: string): DockerFetch { + return async (path, opts) => { + recordCall(calls, path, opts); + if (path.includes("/containers/create")) { + return Response.json({ Id: id }, { status: 201 }); + } + return new Response("", { status: path.endsWith("/start") ? 204 : 200 }); + }; +} + +beforeEach(() => { + process.env.HOSTNAME = ""; + db.query("DELETE FROM registry_credentials").run(); +}); + +describe("buildImageStreaming", () => { + test("parses chunked JSON frames, omits progress frames, and sends build params", async () => { + const calls: DockerCall[] = []; + const imageId = "sha256:abcdef1234567890abcdef"; + const fetchImpl: DockerFetch = async (path, opts) => { + recordCall(calls, path, opts); + return streamResponse([ + '{"stream":"Step 1/2\\n"}\n{"status":"Downloading","id":"layer1","progress":"[=>]"}\n', + `{"status":"Download complete","id":"layer1"}\n{"aux":{"ID":"${imageId}"}}\npla`, + "in text\n", + ]); + }; + const lines: string[] = []; + + await buildImageStreaming( + "https://github.com/acme/app", + "main", + "Dockerfile.prod", + "moor/app:latest", + (line) => lines.push(line), + true, + undefined, + fetchImpl, + ); + + expect(lines).toEqual([ + "Step 1/2\n", + "layer1: Download complete\n", + `Built image: ${imageId.slice(0, 19)}\n`, + "plain text\n", + ]); + expect(calls).toHaveLength(1); + const url = new URL(`http://localhost${calls[0].path}`); + expect(url.pathname).toBe("/v1.44/build"); + expect(url.searchParams.get("remote")).toBe("https://github.com/acme/app.git#main"); + expect(url.searchParams.get("dockerfile")).toBe("Dockerfile.prod"); + expect(url.searchParams.get("t")).toBe("moor/app:latest"); + expect(url.searchParams.get("nocache")).toBe("true"); + expect(calls[0].method).toBe("POST"); + expect(calls[0].timeout).toBe(1_800_000); + }); + + test("redacts Docker error frames while preserving build-error classification", async () => { + const fetchImpl: DockerFetch = async () => + streamResponse([ + `${JSON.stringify({ + error: [ + "fatal: could not read Username for", + "'https://user:secret@github.com/acme/private': terminal prompts disabled", + ].join(" "), + })}\n`, + ]); + const lines: string[] = []; + let thrown: unknown; + + try { + await buildImageStreaming( + "https://user:secret@github.com/acme/private", + "main", + "Dockerfile", + "moor/private:latest", + (line) => lines.push(line), + false, + undefined, + fetchImpl, + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + const message = thrown instanceof Error ? thrown.message : ""; + expect(message).toContain("terminal prompts disabled"); + expect(message).not.toContain("secret"); + expect(lines.join("")).not.toContain("secret"); + expect(classifyBuildError(message)).toBe("source_credential_required"); + }); +}); + +describe("pullImageStreaming", () => { + test("parses progress output and sends X-Registry-Auth from stored credentials", async () => { + createCredential({ hostname: "ghcr.io", username: "alice", secret: "ghp_secret" }); + const calls: DockerCall[] = []; + const fetchImpl: DockerFetch = async (path, opts) => { + recordCall(calls, path, opts); + if (path === "/v1.44/version") { + return Response.json({ Os: "linux", Arch: "amd64" }); + } + return streamResponse([ + '{"status":"Pulling from acme/app"}\n', + '{"status":"Downloading","id":"layer1","progress":"[==>]"}\n', + '{"status":"Download complete","id":"layer1"}\n', + ]); + }; + const lines: string[] = []; + + await pullImageStreaming( + "ghcr.io/acme/app:1.2.3", + (line) => lines.push(line), + undefined, + fetchImpl, + ); + + expect(lines).toEqual(["Pulling from acme/app\n", "layer1: Download complete\n"]); + const createCall = calls.find((call) => call.path.startsWith("/v1.44/images/create?")); + expect(createCall).toBeDefined(); + if (!createCall) throw new Error("missing image create call"); + const url = new URL(`http://localhost${createCall.path}`); + expect(url.searchParams.get("fromImage")).toBe("ghcr.io/acme/app"); + expect(url.searchParams.get("tag")).toBe("1.2.3"); + expect(url.searchParams.get("platform")).toBe("linux/amd64"); + + const authHeader = headerValue(createCall.headers, "X-Registry-Auth"); + expect(authHeader).toBeTruthy(); + if (!authHeader) throw new Error("missing X-Registry-Auth header"); + const decoded = JSON.parse(Buffer.from(authHeader, "base64url").toString("utf8")) as { + username: string; + password: string; + serveraddress: string; + }; + expect(decoded).toEqual({ + username: "alice", + password: "ghp_secret", + serveraddress: "ghcr.io", + }); + }); + + test("throws Docker pull error frames after emitting the parsed error line", async () => { + const fetchImpl: DockerFetch = async (path) => { + if (path === "/v1.44/version") return new Response("", { status: 404 }); + return streamResponse(['{"error":"manifest unknown"}\n']); + }; + const lines: string[] = []; + + await expect( + pullImageStreaming( + "ghcr.io/acme/missing:latest", + (line) => lines.push(line), + undefined, + fetchImpl, + ), + ).rejects.toThrow("ERROR: manifest unknown"); + expect(lines).toEqual(["ERROR: manifest unknown\n"]); + }); +}); + +describe("container create body and file injection", () => { + test("builds env, ports, volumes, restart policy, labels, command, and entrypoint", () => { + const body = buildContainerCreateBody({ + imageTag: "ghcr.io/acme/app:1.2.3", + envVars: [ + { key: "NODE_ENV", value: "production" }, + { key: "TOKEN", value: "secret" }, + ], + ports: [ + { host_port: 18080, container_port: 8080 }, + { host_port: 19090, container_port: 9090 }, + ], + restartPolicy: "on-failure", + limits: { memoryLimitMb: 256, cpus: 1.5 }, + volumes: [{ docker_name: "moor_data", target: "/data" }], + labels: { "sh.moor.project_id": "42", "com.example.role": "api" }, + command: ["bun", "start"], + entrypoint: ["/usr/bin/env"], + }); + + expect(body.Image).toBe("ghcr.io/acme/app:1.2.3"); + expect(body.Env).toEqual(["NODE_ENV=production", "TOKEN=secret"]); + expect(body.ExposedPorts).toEqual({ "8080/tcp": {}, "9090/tcp": {} }); + expect(body.Labels).toEqual({ "sh.moor.project_id": "42", "com.example.role": "api" }); + expect(body.Cmd).toEqual(["bun", "start"]); + expect(body.Entrypoint).toEqual(["/usr/bin/env"]); + + const hostConfig = body.HostConfig as Record; + expect(hostConfig.RestartPolicy).toEqual({ Name: "on-failure" }); + expect(hostConfig.PortBindings).toEqual({ + "8080/tcp": [{ HostIp: "127.0.0.1", HostPort: "18080" }], + "9090/tcp": [{ HostIp: "127.0.0.1", HostPort: "19090" }], + }); + expect(hostConfig.Mounts).toEqual([{ Type: "volume", Source: "moor_data", Target: "/data" }]); + expect(hostConfig.Memory).toBe(268_435_456); + expect(hostConfig.MemorySwap).toBe(268_435_456); + expect(hostConfig.NanoCpus).toBe(1_500_000_000); + }); + + test("sends the create request body and injects declared files before start", async () => { + const calls: DockerCall[] = []; + const files: ResolvedFile[] = [ + { path: "/etc/moor/config.json", content: '{"ok":true}', mode: 0o600 }, + ]; + + const id = await createAndStartContainer( + "ghcr.io/acme/app:1.2.3", + "moor-api", + [{ key: "NODE_ENV", value: "production" }], + [{ host_port: 18080, container_port: 8080 }], + "unless-stopped", + {}, + [{ docker_name: "moor_api_data", target: "/var/lib/app" }], + { "sh.moor.project_name": "api" }, + { command: ["serve"], entrypoint: ["/entrypoint.sh"], files }, + dockerFetchForCreate(calls, "container-created"), + ); + + expect(id).toBe("container-created"); + const createIdx = calls.findIndex((call) => call.path.includes("/containers/create")); + const archiveIdx = calls.findIndex((call) => call.path.includes("/archive")); + const startIdx = calls.findIndex((call) => call.path.endsWith("/start")); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(archiveIdx).toBeGreaterThan(createIdx); + expect(startIdx).toBeGreaterThan(archiveIdx); + + const createBody = parseJsonBody>(calls[createIdx].body); + expect(createBody.Image).toBe("ghcr.io/acme/app:1.2.3"); + expect(createBody.Env).toEqual(["NODE_ENV=production"]); + expect(createBody.Cmd).toEqual(["serve"]); + expect(createBody.Entrypoint).toEqual(["/entrypoint.sh"]); + expect(createBody.Labels).toEqual({ "sh.moor.project_name": "api" }); + + const archiveCall = calls[archiveIdx]; + expect(headerValue(archiveCall.headers, "Content-Type")).toBe("application/x-tar"); + expect(archiveCall.body).toBeInstanceOf(Uint8Array); + const tar = archiveCall.body as Uint8Array; + expect(tarField(tar, 0, 100)).toBe("etc/moor/config.json"); + expect(Number.parseInt(tarField(tar, 100, 8), 8)).toBe(0o600); + expect(tarContent(tar, '{"ok":true}'.length)).toBe('{"ok":true}'); + }); +}); + +describe("compose network attach", () => { + test("discovers the compose project and connects the new container before start", async () => { + process.env.HOSTNAME = "moor-self"; + const calls: DockerCall[] = []; + const fetchImpl: DockerFetch = async (path, opts) => { + recordCall(calls, path, opts); + if (path.includes("/containers/create")) { + return Response.json({ Id: "container-net" }, { status: 201 }); + } + if (path === "/v1.44/containers/moor-self/json") { + return Response.json({ + Config: { Labels: { "com.docker.compose.project": "moorproj" } }, + }); + } + if (path.startsWith("/v1.44/networks?")) { + return Response.json([{ Name: "moorproj_default" }]); + } + if (path.endsWith("/connect")) { + return new Response("endpoint with name moor-api already exists", { status: 403 }); + } + return new Response("", { status: path.endsWith("/start") ? 204 : 200 }); + }; + + const id = await createAndStartContainer( + "alpine:latest", + "moor-api", + [], + [], + "unless-stopped", + {}, + [], + {}, + {}, + fetchImpl, + ); + + expect(id).toBe("container-net"); + const networksCall = calls.find((call) => call.path.startsWith("/v1.44/networks?")); + expect(networksCall).toBeDefined(); + if (!networksCall) throw new Error("missing network list call"); + const filters = new URL(`http://localhost${networksCall.path}`).searchParams.get("filters"); + expect(filters ? JSON.parse(filters) : null).toEqual({ + label: ["com.docker.compose.project=moorproj", "com.docker.compose.network=default"], + }); + + const connectIdx = calls.findIndex((call) => call.path.endsWith("/connect")); + const startIdx = calls.findIndex((call) => call.path.endsWith("/start")); + expect(connectIdx).toBeGreaterThanOrEqual(0); + expect(startIdx).toBeGreaterThan(connectIdx); + expect(calls[connectIdx].path).toBe("/v1.44/networks/moorproj_default/connect"); + expect(parseJsonBody<{ Container: string }>(calls[connectIdx].body)).toEqual({ + Container: "container-net", + }); + }); + + test("network attach failure removes the created container and does not start it", async () => { + process.env.HOSTNAME = "moor-self"; + const calls: DockerCall[] = []; + const fetchImpl: DockerFetch = async (path, opts) => { + recordCall(calls, path, opts); + if (path.includes("/containers/create")) { + return Response.json({ Id: "container-net-fail" }, { status: 201 }); + } + if (path === "/v1.44/containers/moor-self/json") { + return Response.json({ + Config: { Labels: { "com.docker.compose.project": "moorproj" } }, + }); + } + if (path.startsWith("/v1.44/networks?")) { + return Response.json([{ Name: "moorproj_default" }]); + } + if (path.endsWith("/connect")) { + return new Response("bridge unavailable", { status: 500 }); + } + return new Response("", { status: 204 }); + }; + + await expect( + createAndStartContainer( + "alpine:latest", + "moor-api", + [], + [], + "unless-stopped", + {}, + [], + {}, + {}, + fetchImpl, + ), + ).rejects.toThrow("Network connect failed (500): bridge unavailable"); + + expect(calls.some((call) => call.path.endsWith("/start"))).toBe(false); + expect( + calls.some((call) => call.path === "/v1.44/containers/container-net-fail?force=true"), + ).toBe(true); + }); +}); + +describe("start cleanup and status handling", () => { + test("start status 304 is accepted and does not remove the created container", async () => { + const calls: DockerCall[] = []; + const fetchImpl: DockerFetch = async (path, opts) => { + recordCall(calls, path, opts); + if (path.includes("/containers/create")) { + return Response.json({ Id: "container-304" }, { status: 201 }); + } + if (path.endsWith("/start")) return new Response("", { status: 304 }); + return new Response("", { status: 404 }); + }; + + const id = await createAndStartContainer( + "alpine:latest", + "moor-api", + [], + [], + "unless-stopped", + {}, + [], + {}, + {}, + fetchImpl, + ); + + expect(id).toBe("container-304"); + expect(calls.some((call) => call.path === "/v1.44/containers/container-304?force=true")).toBe( + false, + ); + }); + + test("failed start removes the created container and rethrows the start error", async () => { + const calls: DockerCall[] = []; + const fetchImpl: DockerFetch = async (path, opts) => { + recordCall(calls, path, opts); + if (path.includes("/containers/create")) { + return Response.json({ Id: "container-failed" }, { status: 201 }); + } + if (path.endsWith("/start")) return new Response("boom", { status: 500 }); + return new Response("", { status: 204 }); + }; + + await expect( + createAndStartContainer( + "alpine:latest", + "moor-api", + [], + [], + "unless-stopped", + {}, + [], + {}, + {}, + fetchImpl, + ), + ).rejects.toThrow("Container start failed: boom"); + + const startIdx = calls.findIndex((call) => call.path.endsWith("/start")); + const cleanupIdx = calls.findIndex( + (call) => call.path === "/v1.44/containers/container-failed?force=true", + ); + expect(startIdx).toBeGreaterThanOrEqual(0); + expect(cleanupIdx).toBeGreaterThan(startIdx); + }); +}); diff --git a/apps/api/docker.ts b/apps/api/docker.ts index 341cba8..39514c7 100644 --- a/apps/api/docker.ts +++ b/apps/api/docker.ts @@ -76,11 +76,11 @@ async function dockerFetch( /** Resolve the compose project name by inspecting moor's own container labels. * Docker sets HOSTNAME to the container short ID by default. Cached lazily. */ let cachedProject: string | null = null; -export async function getComposeProject(): Promise { - if (cachedProject) return cachedProject; +export async function getComposeProject(fetchImpl: DockerFetch = dockerFetch): Promise { + if (fetchImpl === dockerFetch && cachedProject) return cachedProject; const hostname = process.env.HOSTNAME; if (!hostname) throw new Error("HOSTNAME env var is empty - cannot self-inspect"); - const res = await dockerFetch(`/v1.44/containers/${hostname}/json`); + const res = await fetchImpl(`/v1.44/containers/${hostname}/json`); if (!res.ok) { throw new Error(`Self-inspect failed (status ${res.status}); not running under compose?`); } @@ -89,18 +89,18 @@ export async function getComposeProject(): Promise { if (!project) { throw new Error("com.docker.compose.project label missing on self"); } - cachedProject = project; + if (fetchImpl === dockerFetch) cachedProject = project; return project; } /** Find the Caddy container by compose labels for the current project. * Returns the container ID. Throws if not found. */ -export async function findCaddyContainerId(): Promise { - const project = await getComposeProject(); +export async function findCaddyContainerId(fetchImpl: DockerFetch = dockerFetch): Promise { + const project = await getComposeProject(fetchImpl); const filters = JSON.stringify({ label: [`com.docker.compose.project=${project}`, "com.docker.compose.service=caddy"], }); - const res = await dockerFetch(`/v1.44/containers/json?filters=${encodeURIComponent(filters)}`); + const res = await fetchImpl(`/v1.44/containers/json?filters=${encodeURIComponent(filters)}`); if (!res.ok) throw new Error(`Container list failed: ${res.status}`); const containers = (await res.json()) as Array<{ Id: string }>; if (containers.length === 0) { @@ -111,12 +111,14 @@ export async function findCaddyContainerId(): Promise { /** Find the default compose network for the current project. * Returns the network name (suitable for /networks/{name}/connect). */ -export async function findDefaultNetworkName(): Promise { - const project = await getComposeProject(); +export async function findDefaultNetworkName( + fetchImpl: DockerFetch = dockerFetch, +): Promise { + const project = await getComposeProject(fetchImpl); const filters = JSON.stringify({ label: [`com.docker.compose.project=${project}`, "com.docker.compose.network=default"], }); - const res = await dockerFetch(`/v1.44/networks?filters=${encodeURIComponent(filters)}`); + const res = await fetchImpl(`/v1.44/networks?filters=${encodeURIComponent(filters)}`); if (!res.ok) throw new Error(`Network list failed: ${res.status}`); const networks = (await res.json()) as Array<{ Name: string }>; if (networks.length === 0) { @@ -165,6 +167,7 @@ export async function buildImageStreaming( onLine: (text: string) => void, noCache = false, signal?: AbortSignal, + fetchImpl: DockerFetch = dockerFetch, ): Promise { const gitUrl = cloneUrl.endsWith(".git") ? cloneUrl : `${cloneUrl}.git`; const remote = `${gitUrl}#${branch}`; @@ -176,7 +179,7 @@ export async function buildImageStreaming( // #68: signal lets BuildRun.cancel() tear down the daemon-side build. // Live test confirmed closing the /v1.44/build connection aborts the // build immediately (classic builder; BuildKit untested). - const res = await dockerFetch(`/v1.44/build?${params}`, { + const res = await fetchImpl(`/v1.44/build?${params}`, { method: "POST", timeout: BUILD_TIMEOUT, signal, @@ -249,6 +252,7 @@ export async function pullImageStreaming( imageRef: string, onLine: (text: string) => void, signal?: AbortSignal, + fetchImpl: DockerFetch = dockerFetch, ): Promise { const parsed = parseImageRef(imageRef); const params = new URLSearchParams(); @@ -258,7 +262,7 @@ export async function pullImageStreaming( // Explicitly set platform to avoid manifest parsing failures on multi-arch images. // /version is called anonymously: no registry credential involved. try { - const versionRes = await dockerFetch("/v1.44/version"); + const versionRes = await fetchImpl("/v1.44/version"); if (versionRes.ok) { const version = (await versionRes.json()) as { Os: string; Arch: string }; params.set("platform", `${version.Os}/${version.Arch}`); @@ -276,7 +280,7 @@ export async function pullImageStreaming( `[pullImageStreaming] fromImage=${parsed.fromImage} tag=${parsed.tag ?? "(digest)"} platform=${params.get("platform") ?? "auto"} auth=${credential ? `host=${parsed.registryHost}` : "anonymous"}`, ); - const res = await dockerFetch(`/v1.44/images/create?${params}`, { + const res = await fetchImpl(`/v1.44/images/create?${params}`, { method: "POST", headers: authHeaders, timeout: BUILD_TIMEOUT, @@ -484,9 +488,7 @@ export async function createAndStartContainer( volumes: Array<{ docker_name: string; target: string }> = [], labels: Record = {}, extras: ContainerExtras = {}, - // Injectable for tests; production uses the module dockerFetch. Note the - // compose-network resolution still goes through the module fetch — tests run - // outside compose (HOSTNAME unset), so that path short-circuits to skip. + // Injectable for tests; production uses the module dockerFetch. fetchImpl: DockerFetch = dockerFetch, ): Promise { const files = extras.files ?? []; @@ -535,7 +537,7 @@ export async function createAndStartContainer( // propagates - otherwise we'd be starting a container Caddy cannot reach. let underCompose = false; try { - await getComposeProject(); + await getComposeProject(fetchImpl); underCompose = true; } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -547,43 +549,47 @@ export async function createAndStartContainer( // orphan here — otherwise it lingers in `Created` state and project delete // can't reap it (the row has container_id = NULL). Cleanup is best-effort and // never masks the original failure. - await startOrCleanup(Id, async () => { - if (underCompose) { - const networkName = await findDefaultNetworkName(); - const connectRes = await fetchImpl( - `/v1.44/networks/${encodeURIComponent(networkName)}/connect`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ Container: Id }), - }, - ); - if (!connectRes.ok) { - const detail = await connectRes.text(); - // Docker returns 403 with "endpoint with name X already exists" or - // "Container already attached" when the container is already on this - // network. Treat that case as success; everything else is a real error. - const alreadyConnected = - connectRes.status === 403 && /already|endpoint .* exists/i.test(detail); - if (!alreadyConnected) { - throw new Error(`Network connect failed (${connectRes.status}): ${detail}`); + await startOrCleanup( + Id, + async () => { + if (underCompose) { + const networkName = await findDefaultNetworkName(fetchImpl); + const connectRes = await fetchImpl( + `/v1.44/networks/${encodeURIComponent(networkName)}/connect`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ Container: Id }), + }, + ); + if (!connectRes.ok) { + const detail = await connectRes.text(); + // Docker returns 403 with "endpoint with name X already exists" or + // "Container already attached" when the container is already on this + // network. Treat that case as success; everything else is a real error. + const alreadyConnected = + connectRes.status === 403 && /already|endpoint .* exists/i.test(detail); + if (!alreadyConnected) { + throw new Error(`Network connect failed (${connectRes.status}): ${detail}`); + } } + console.log(`[createContainer] connected ${name} to ${networkName}`); } - console.log(`[createContainer] connected ${name} to ${networkName}`); - } - // Inject declarative files BEFORE start so the process sees them on boot. - // Runs on every (re)create, same lifecycle point as env. - if (files.length > 0) { - await putContainerArchive(Id, files, fetchImpl); - } + // Inject declarative files BEFORE start so the process sees them on boot. + // Runs on every (re)create, same lifecycle point as env. + if (files.length > 0) { + await putContainerArchive(Id, files, fetchImpl); + } - const startRes = await fetchImpl(`/v1.44/containers/${Id}/start`, { method: "POST" }); - if (!startRes.ok && startRes.status !== 304) { - const err = await startRes.text(); - throw new Error(`Container start failed: ${err}`); - } - }); + const startRes = await fetchImpl(`/v1.44/containers/${Id}/start`, { method: "POST" }); + if (!startRes.ok && startRes.status !== 304) { + const err = await startRes.text(); + throw new Error(`Container start failed: ${err}`); + } + }, + async (containerId) => removeContainerWithFetch(containerId, fetchImpl), + ); return Id; } @@ -604,8 +610,15 @@ export async function stopContainer(containerId: string): Promise { } export async function removeContainer(containerId: string): Promise { + await removeContainerWithFetch(containerId, dockerFetch); +} + +async function removeContainerWithFetch( + containerId: string, + fetchImpl: DockerFetch, +): Promise { console.log(`[removeContainer] removing ${containerId.slice(0, 12)}...`); - const res = await dockerFetch(`/v1.44/containers/${containerId}?force=true`, { + const res = await fetchImpl(`/v1.44/containers/${containerId}?force=true`, { method: "DELETE", }); // 404 = already gone, which is success for a force-remove. Any other non-2xx diff --git a/apps/api/github-url.ts b/apps/api/github-url.ts new file mode 100644 index 0000000..f5801d9 --- /dev/null +++ b/apps/api/github-url.ts @@ -0,0 +1,12 @@ +export function validateGithubUrl(url: string): string | null { + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (parsed.protocol !== "https:" || (host !== "github.com" && host !== "www.github.com")) { + return "Only GitHub URLs are supported"; + } + } catch { + return "Invalid GitHub URL"; + } + return null; +} diff --git a/apps/api/http.ts b/apps/api/http.ts new file mode 100644 index 0000000..2b7e4a3 --- /dev/null +++ b/apps/api/http.ts @@ -0,0 +1,51 @@ +export function errorResponse(message: string, status: number): Response { + return Response.json({ error: message }, { status }); +} + +export type JsonObjectResult = + | { ok: true; value: Record } + | { ok: false; response: Response }; + +export async function readJsonObject(req: Request): Promise { + let raw: unknown; + try { + raw = await req.json(); + } catch { + return { ok: false, response: errorResponse("invalid JSON body", 400) }; + } + + if (!isJsonObject(raw)) { + return { + ok: false, + response: errorResponse("request body must be a JSON object", 400), + }; + } + + return { ok: true, value: raw }; +} + +export async function responseErrorMessage(response: Response): Promise { + const text = await response.text(); + return parseErrorMessage(text, response.status); +} + +// Mirrors packages/contract parseErrorMessage; the API stays dependency-free. +function parseErrorMessage(body: string, status: number): string { + if (!body) return `HTTP ${status}`; + + try { + const parsed = JSON.parse(body) as unknown; + if (isJsonObject(parsed) && "error" in parsed) { + const error = parsed.error; + return typeof error === "string" ? error : JSON.stringify(error); + } + } catch { + return body; + } + + return body; +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/api/index.ts b/apps/api/index.ts index fda8b46..6959a5d 100644 --- a/apps/api/index.ts +++ b/apps/api/index.ts @@ -20,6 +20,7 @@ import { maybeAutoClearForBoot } from "./drain"; import { interruptActiveExecRuns } from "./exec-async"; import { startHistoryRetention, stopHistoryRetention } from "./history-retention"; import { hostTerminalHandlers, isHostTerminal, upgradeHostTerminal } from "./host-terminal"; +import { errorResponse } from "./http"; import { type HostSample, startMetricsSampler, stopMetricsSampler } from "./metrics-sampler"; import { handleAuth } from "./routes/auth"; import { handleCaddy } from "./routes/caddy"; @@ -168,7 +169,7 @@ const server = Bun.serve({ const origin = req.headers.get("origin"); const host = req.headers.get("host"); if (origin && host && !origin.includes(host)) { - return new Response("Origin mismatch", { status: 403 }); + return errorResponse("Origin mismatch", 403); } if (url.pathname === "/api/terminal") { @@ -204,12 +205,9 @@ const server = Bun.serve({ } catch (e) { console.error("[api error]", e); const message = e instanceof Error ? e.message : "Internal server error"; - return new Response(JSON.stringify({ error: message }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + return errorResponse(message, 500); } - return new Response("Not found", { status: 404 }); + return errorResponse("Not found", 404); } // Serve built client (production) diff --git a/apps/api/routes/caddy.ts b/apps/api/routes/caddy.ts index 42c55ad..65327d1 100644 --- a/apps/api/routes/caddy.ts +++ b/apps/api/routes/caddy.ts @@ -1,10 +1,11 @@ import { checkDns } from "../caddy"; +import { errorResponse } from "../http"; export async function handleCaddy(req: Request, url: URL): Promise { if (url.pathname === "/api/dns-check" && req.method === "POST") { const body = (await req.json()) as { domain?: string }; if (!body.domain?.trim()) { - return new Response("domain is required", { status: 400 }); + return errorResponse("domain is required", 400); } const result = await checkDns(body.domain.trim()); return Response.json(result); diff --git a/apps/api/routes/cleanup.ts b/apps/api/routes/cleanup.ts index 8ba133c..fc26aac 100644 --- a/apps/api/routes/cleanup.ts +++ b/apps/api/routes/cleanup.ts @@ -4,29 +4,30 @@ // "DELETE there preserves Docker data" contract introduced in #35. import { executeCleanup, planCleanup, validateExecuteCandidates, validateScope } from "../cleanup"; +import { errorResponse } from "../http"; export async function handleCleanup(req: Request, url: URL): Promise { if (url.pathname === "/api/server/cleanup/plan" && req.method === "POST") { const body = await req.json().catch(() => ({})); const scope = validateScope((body as { scope?: unknown }).scope); - if (!scope.ok) return new Response(scope.error, { status: 400 }); + if (!scope.ok) return errorResponse(scope.error, 400); try { return Response.json(await planCleanup(scope.value)); } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; - return Response.json({ error: msg }, { status: 500 }); + return errorResponse(msg, 500); } } if (url.pathname === "/api/server/cleanup/execute" && req.method === "POST") { const body = (await req.json().catch(() => ({}))) as { candidates?: unknown }; const candidates = validateExecuteCandidates(body.candidates); - if (!candidates.ok) return new Response(candidates.error, { status: 400 }); + if (!candidates.ok) return errorResponse(candidates.error, 400); try { return Response.json(await executeCleanup(candidates.value)); } catch (e) { const msg = e instanceof Error ? e.message : "Unknown error"; - return Response.json({ error: msg }, { status: 500 }); + return errorResponse(msg, 500); } } diff --git a/apps/api/routes/container-stats.ts b/apps/api/routes/container-stats.ts index 55c3720..73e1012 100644 --- a/apps/api/routes/container-stats.ts +++ b/apps/api/routes/container-stats.ts @@ -14,6 +14,7 @@ import { } from "../container-stats"; import db from "../db"; import { SOCKET as SOCKET_PATH } from "../docker"; +import { errorResponse } from "../http"; export async function handleContainerStats(req: Request, url: URL): Promise { const match = url.pathname.match(/^\/api\/projects\/(\d+)\/container-stats$/); @@ -23,7 +24,7 @@ export async function handleContainerStats(req: Request, url: URL): Promise { + return ((await res.json()) as { error: string }).error; +} + async function call(method: string, path: string): Promise { const req = new Request(`http://localhost${path}`, { method }); const res = await handleCrons(req, new URL(req.url)); @@ -37,7 +41,7 @@ describe("#73 POST /api/crons/:id/run live-check wiring", () => { const res = await call("POST", `/api/crons/${cron.id}/run`); expect(res.status).toBe(400); - expect(await res.text()).toBe("Project has no container; build/start it first"); + expect(await errorMessage(res)).toBe("Project has no container; build/start it first"); // Manual trigger should NOT have created a run row when the live // check rejected — the run row gets created inside runCron, which // we never reach. diff --git a/apps/api/routes/crons.ts b/apps/api/routes/crons.ts index f729c69..161bd58 100644 --- a/apps/api/routes/crons.ts +++ b/apps/api/routes/crons.ts @@ -1,6 +1,7 @@ import { runCron } from "../cron"; import db from "../db"; import { requireNotDraining } from "../drain"; +import { errorResponse } from "../http"; import { liveRequireErrorResponse, requireLiveContainer } from "../status-reconciler"; export async function handleCrons(req: Request, url: URL): Promise { @@ -51,12 +52,12 @@ export async function handleCrons(req: Request, url: URL): Promise { const { name, schedule, command } = await req.json(); if (!name || !schedule || !command) { - return new Response("name, schedule, and command are required", { status: 400 }); + return errorResponse("name, schedule, and command are required", 400); } const row = db @@ -98,13 +99,13 @@ async function handleUpdate(req: Request, id: number): Promise { } } - if (fields.length === 0) return new Response("No fields to update", { status: 400 }); + if (fields.length === 0) return errorResponse("No fields to update", 400); values.push(id); const row = db .query(`UPDATE crons SET ${fields.join(", ")} WHERE id = ? RETURNING *`) .get(...values); - if (!row) return new Response("Not found", { status: 404 }); + if (!row) return errorResponse("Not found", 404); return Response.json(row); } diff --git a/apps/api/routes/docker.test.ts b/apps/api/routes/docker.test.ts index 30045c3..6423d83 100644 --- a/apps/api/routes/docker.test.ts +++ b/apps/api/routes/docker.test.ts @@ -23,6 +23,10 @@ async function call(method: string, path: string, body?: unknown): Promise { + return ((await res.json()) as { error: string }).error; +} + function insertProject(name: string): { id: number } { return db .query( @@ -43,7 +47,7 @@ describe("#34 POST /exec timeout_ms validation", () => { timeout_ms: 500, }); expect(res.status).toBe(400); - expect(await res.text()).toContain("timeout_ms must be an integer between"); + expect(await errorMessage(res)).toContain("timeout_ms must be an integer between"); }); test("rejects timeout_ms above the maximum", async () => { @@ -53,7 +57,7 @@ describe("#34 POST /exec timeout_ms validation", () => { timeout_ms: 3_600_001, }); expect(res.status).toBe(400); - expect(await res.text()).toContain("timeout_ms must be an integer between"); + expect(await errorMessage(res)).toContain("timeout_ms must be an integer between"); }); test("rejects non-integer timeout_ms", async () => { @@ -63,6 +67,7 @@ describe("#34 POST /exec timeout_ms validation", () => { timeout_ms: 5000.5, }); expect(res.status).toBe(400); + expect(await errorMessage(res)).toContain("timeout_ms must be an integer between"); }); test("rejects negative timeout_ms", async () => { @@ -72,6 +77,7 @@ describe("#34 POST /exec timeout_ms validation", () => { timeout_ms: -1, }); expect(res.status).toBe(400); + expect(await errorMessage(res)).toContain("timeout_ms must be an integer between"); }); test("accepts a valid timeout_ms and reaches the Docker layer", async () => { @@ -97,7 +103,7 @@ describe("#34 POST /exec timeout_ms validation", () => { const p = insertProject("g"); const res = await call("POST", `/api/projects/${p.id}/exec`, { timeout_ms: 30_000 }); expect(res.status).toBe(400); - expect(await res.text()).toBe("Missing command"); + expect(await errorMessage(res)).toBe("Missing command"); }); test("returns 400 when the project's container is not running, regardless of timeout_ms", async () => { @@ -116,7 +122,7 @@ describe("#34 POST /exec timeout_ms validation", () => { // for both no-container and not-running cases; #73 distinguishes // them (no_container=400, not_running=409 with live_status). expect(res.status).toBe(400); - expect(await res.text()).toBe("Project has no container; build/start it first"); + expect(await errorMessage(res)).toBe("Project has no container; build/start it first"); }); }); @@ -283,12 +289,16 @@ describe("#112 build path credential resolution", () => { return row.id; } - function makeGithubProject(name: string, source_credential_id: number | null = null): number { + function makeGithubProject( + name: string, + source_credential_id: number | null = null, + githubUrl = "https://github.com/owner/repo", + ): number { const row = db .query( - "INSERT INTO projects (name, github_url, branch, dockerfile, restart_policy, status, source_credential_id) VALUES (?, 'https://github.com/owner/repo', 'main', 'Dockerfile', 'unless-stopped', 'stopped', ?) RETURNING id", + "INSERT INTO projects (name, github_url, branch, dockerfile, restart_policy, status, source_credential_id) VALUES (?, ?, 'main', 'Dockerfile', 'unless-stopped', 'stopped', ?) RETURNING id", ) - .get(name, source_credential_id) as { id: number }; + .get(name, githubUrl, source_credential_id) as { id: number }; return row.id; } @@ -335,6 +345,58 @@ describe("#112 build path credential resolution", () => { expect(statusOf(pId)).toBe("stopped"); }); + test("github.com and www.github.com URLs pass route URL validation", async () => { + const wrongHost = makeCred("gitlab.com", "validation-wrong-host"); + const cases = [ + ["github", "https://github.com/owner/repo", "github.com"], + ["www-github", "https://www.github.com/owner/repo", "www.github.com"], + ] as const; + + for (const [name, githubUrl, requestHostname] of cases) { + const pId = makeGithubProject(`p-${name}`, wrongHost, githubUrl); + const res = await call("POST", `/api/projects/${pId}/build`); + expect(res.status).toBe(400); + const body = (await res.json()) as { code: string; request_hostname: string }; + expect(body.code).toBe("credential_host_mismatch"); + expect(body.request_hostname).toBe(requestHostname); + expect(statusOf(pId)).toBe("stopped"); + } + }); + + test("lookalike hosts, insecure protocol, and GitHub subdomains are rejected", async () => { + const cases = [ + ["evil", "https://evilgithub.com/owner/repo"], + ["http", "http://github.com/owner/repo"], + ["gist", "https://gist.github.com/x"], + ] as const; + + for (const [name, githubUrl] of cases) { + const pId = makeGithubProject(`p-${name}`, null, githubUrl); + const res = await call("POST", `/api/projects/${pId}/build`); + expect(res.status).toBe(400); + expect(await errorMessage(res)).toBe("Only GitHub URLs are supported"); + expect(statusOf(pId)).toBe("stopped"); + } + }); + + test("build and start action validation errors use the JSON error envelope", async () => { + const noGithubId = ( + db + .query( + "INSERT INTO projects (name, branch, dockerfile, restart_policy, status) VALUES ('p-no-github', 'main', 'Dockerfile', 'unless-stopped', 'stopped') RETURNING id", + ) + .get() as { id: number } + ).id; + const buildRes = await call("POST", `/api/projects/${noGithubId}/build`); + expect(buildRes.status).toBe(400); + expect(await errorMessage(buildRes)).toBe("No GitHub URL configured"); + + const noImageId = makeGithubProject("p-no-image"); + const startRes = await call("POST", `/api/projects/${noImageId}/start`); + expect(startRes.status).toBe(400); + expect(await errorMessage(startRes)).toBe("No image built yet"); + }); + test("invalid github_url (legacy migration edge) returns 400 BEFORE side effects", async () => { // Project with a non-conformant URL slipping past earlier validation. db.query( @@ -343,12 +405,12 @@ describe("#112 build path credential resolution", () => { const pId = (db.query("SELECT id FROM projects WHERE name = 'p5'").get() as { id: number }).id; const res = await call("POST", `/api/projects/${pId}/build`); // ?branch=main makes parseRepoUrl reject (query string disallowed). - // The legacy validateGithubUrl accepts it, so we DO flip status. But - // the resolver catches it. With v1 we accept the flip; document the - // edge. The point is: build doesn't proceed. + // The route URL validator only checks protocol and host; the resolver + // catches the repo-shape error before the build starts. expect(res.status).toBe(400); const body = (await res.json()) as { code: string }; expect(body.code).toBe("invalid_url"); + expect(statusOf(pId)).toBe("stopped"); }); }); @@ -364,14 +426,12 @@ describe("#119 build-failure classification (pure helpers)", () => { expect(body.message).toBe(msg); }); - test("unclassified error → 500 with the redacted message as text", async () => { + test("unclassified error → 500 with JSON {error}", async () => { const msg = "Docker build failed: 500 some unrelated error"; const res = buildErrorResponse(msg); expect(res.status).toBe(500); - expect(await res.text()).toBe(msg); - // Specifically should NOT be JSON — preserves the legacy contract - // for UI clients that may not handle JSON 500s. - expect(res.headers.get("content-type") || "").not.toContain("application/json"); + expect(res.headers.get("content-type") || "").toContain("application/json"); + expect(await res.json()).toEqual({ error: msg }); }); test("repository-not-found stays unclassified (different remediation)", async () => { diff --git a/apps/api/routes/docker.ts b/apps/api/routes/docker.ts index b6057f7..4e369ef 100644 --- a/apps/api/routes/docker.ts +++ b/apps/api/routes/docker.ts @@ -1,111 +1,29 @@ -import { classifyBuildError } from "../build-error-classifier"; -import { BuildRun } from "../build-runs"; -import { syncCaddyRoutes } from "../caddy"; -import { parseStringArray } from "../container-config"; import db from "../db"; import { - buildImageStreaming, - createAndStartContainer, + buildProject, + deployProject, + type Project, + type ProjectActionResult, + startProject, + stopProject, +} from "../deploy"; +import { EXEC_TIMEOUT_MAX_MS, EXEC_TIMEOUT_MIN_MS, ExecTimeoutError, execInContainer, getContainerLogs, - projectLabels, - pullImageStreaming, - stopContainer, } from "../docker"; import { requireNotDraining } from "../drain"; -import { autoDetectPorts, getProjectPorts } from "../ports"; -import { redactCredentials, redactCredentialsInText } from "../redact"; -import { type ResolveFailure, resolveCredentialForBuild } from "../source-credential-resolver"; +import { validateGithubUrl } from "../github-url"; +import { errorResponse } from "../http"; import { liveRequireErrorResponse, - reconcileProjectStatusAfterInterrupt, requireLiveContainer, setProjectLiveState, - setProjectRecordedStatus, } from "../status-reconciler"; -import { getResolvedProjectFiles } from "./files"; -import { getProjectVolumes } from "./volumes"; - -type Project = { - id: number; - name: string; - github_url: string | null; - docker_image: string | null; - branch: string; - dockerfile: string; - image_tag: string | null; - container_id: string | null; - status: string; - domain: string | null; - domain_port: number | null; - restart_policy: string; - memory_limit_mb: number | null; - cpus: number | null; - source_credential_id: number | null; - // JSON-encoded string arrays (or null). Parsed via parseStringArray before - // they reach the Docker create body as Cmd / Entrypoint. - command: string | null; - entrypoint: string | null; -}; - -/** Map a build-time credential-resolver failure to an HTTP Response. - * All build-time failures are 400 since #120: ambiguity no longer - * surfaces at build time (null source_credential_id means anonymous - * clone, never a host lookup). /check stays the place to surface - * ambiguity to the operator. */ -function resolverFailureResponse(failure: ResolveFailure): Response { - return Response.json(failure, { status: 400 }); -} - -/** Build the /build catch-path response from an already-redacted error - * message. Classified auth failures (#119) become 401 JSON with a - * structured code so agents can branch; unclassified errors keep the - * legacy 500/text contract so existing UI clients are unaffected. - * Exported for unit tests; the route invokes it inline. */ -export function buildErrorResponse(message: string): Response { - const code = classifyBuildError(message); - if (code !== "unknown") { - return Response.json({ code, message }, { status: 401 }); - } - return new Response(message, { status: 500 }); -} - -/** SSE events to emit on a /run catch-path failure from an already- - * redacted error message. structured-error fires first when the failure - * classifies (#119) so a parsing agent can branch on the code; the - * trailing legacy event: error keeps existing UI/CLI/MCP consumers - * working unchanged. Exported for unit tests; the route emits via send(). */ -export function buildErrorEvents( - message: string, -): Array< - | { event: "structured-error"; data: { code: string; message: string } } - | { event: "error"; data: string } -> { - const code = classifyBuildError(message); - const events: ReturnType = []; - if (code !== "unknown") { - events.push({ event: "structured-error", data: { code, message } }); - } - events.push({ event: "error", data: message }); - return events; -} - -function validateGithubUrl(url: string): string | null { - try { - const parsed = new URL(url); - if (!parsed.hostname.endsWith("github.com")) return "Only GitHub URLs are supported"; - } catch { - return "Invalid GitHub URL"; - } - return null; -} -// #77: reconcileStatusAfterCancel moved to status-reconciler.ts as -// reconcileProjectStatusAfterInterrupt so the shutdown coordinator can -// reuse the same logic. Route callsites below import it from there. +export { buildErrorEvents, buildErrorResponse } from "../deploy"; export async function handleDocker(req: Request, url: URL): Promise { const match = url.pathname.match(/^\/api\/projects\/(\d+)\/(build|start|stop|run|logs|exec)$/); @@ -118,7 +36,7 @@ export async function handleDocker(req: Request, url: URL): Promise { - // #79: drain-mode gate. Cheapest check first — refuse new deploys - // before parsing URL or touching Docker. handleStart fallback below - // also goes through its own gate. - const drained = requireNotDraining(); - if (drained) return drained; +function projectActionResultToResponse(result: ProjectActionResult): Response { + switch (result.kind) { + case "response": + return result.response; + case "json": + return result.status === undefined + ? Response.json(result.body) + : Response.json(result.body, { status: result.status }); + case "stream": + return new Response(result.stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } +} +async function handleRun(req: Request, project: Project): Promise { const url = new URL(req.url); const noCache = url.searchParams.get("nocache") === "true"; - const isImageProject = !!project.docker_image; - console.log( - `[run] starting run for project ${project.name} (id=${project.id}) type=${isImageProject ? "image" : "github"} nocache=${noCache}`, - ); - - if (!project.github_url && !project.docker_image) { - if (project.image_tag) { - console.log("[run] no source, starting existing image"); - return handleStart(project); - } - console.log("[run] no source or image_tag — nothing to do"); - return new Response("No GitHub URL or Docker image configured", { status: 400 }); - } - - if (project.github_url) { - const urlError = validateGithubUrl(project.github_url); - if (urlError) return new Response(urlError, { status: 400 }); - } - - // Resolve source credential BEFORE any side effects (status flip, - // BuildRun row, SSE stream open). A resolver failure should look like - // a validation error to the caller, not a half-built build run. - // Image projects skip resolution; only github_url builds consume creds. - let resolvedCloneUrl: string | null = null; - let resolvedCredentialId: number | null = null; - if (!isImageProject && project.github_url) { - const resolved = resolveCredentialForBuild( - project.github_url, - project.source_credential_id ?? undefined, - ); - if (!resolved.ok) { - return resolverFailureResponse(resolved); - } - resolvedCloneUrl = resolved.value.cloneUrl; - resolvedCredentialId = resolved.value.used_credential_id; - } - - const tag = isImageProject ? project.docker_image! : `moor/${project.name}:latest`; - const status = isImageProject ? "pulling" : "building"; - console.log( - `[run] image tag will be: ${tag}` + - (resolvedCredentialId !== null - ? ` source_credential_id=${resolvedCredentialId}` - : !isImageProject - ? " anonymous-clone" - : ""), - ); - setProjectRecordedStatus(project.id, status, project.container_id); - - // #65: one deploy run row covers build/pull + port detection + container - // start. INSERT before the build starts so moor_run_get can tail mid-build; - // BuildRun periodically flushes the rolling tail into runs.stdout. - const run = new BuildRun(project.id); - - // Stream build/pull output via SSE - let streamClosed = false; - let keepalive: ReturnType; - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - - // data is JSON-stringified, so strings come through as JSON strings - // and objects (used by event: structured-error in #119) come through - // as JSON objects. Consumers JSON.parse once to get the original. - const send = (event: string, data: unknown) => { - if (streamClosed) return; - try { - controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); - } catch { - streamClosed = true; - } - }; - - // Mirror every log line into both the SSE stream (for the UI/CLI) and - // the persistent BuildRun (for moor_run_get). Single source of text. - const log = (line: string) => { - send("log", line); - run.appendStdout(line); - }; - - const safeClose = () => { - clearInterval(keepalive); - if (streamClosed) return; - streamClosed = true; - try { - controller.close(); - } catch { - // Already closed - } - }; - - keepalive = setInterval(() => { - if (streamClosed) return; - try { - controller.enqueue(encoder.encode(":keepalive\n\n")); - } catch { - streamClosed = true; - } - }, 5000); - - const startTime = Date.now(); - - try { - if (isImageProject) { - log(`Pulling ${project.docker_image}...\n`); - await pullImageStreaming(project.docker_image!, log, run.abort.signal); - } else { - // resolvedCloneUrl is guaranteed non-null in this branch - // (resolved above, before side effects). - await buildImageStreaming( - resolvedCloneUrl as string, - project.branch, - project.dockerfile, - tag, - log, - noCache, - run.abort.signal, - ); - } - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - const verb = isImageProject ? "Pull" : "Build"; - - // #68: past this point cancel() can't stop anything useful — the - // container-start phase below uses different Docker endpoints and - // AbortController on the build/pull fetch won't reach them. - run.markStreamingDone(); - - db.query("UPDATE projects SET image_tag = ? WHERE id = ?").run(tag, project.id); - setProjectRecordedStatus(project.id, "stopped", project.container_id); - - log(`\n${verb} completed in ${elapsed}s\n`); - - // Auto-detect exposed ports from image - const detectedPorts = await autoDetectPorts(project.id, tag, true); - for (const { host_port, container_port } of detectedPorts) { - log(`Port ${container_port} → host :${host_port}\n`); - } - } catch (e) { - // #68: if cancel() fired AbortError, BuildRun.cancel already - // finalized the row with exit_code=130 and "[cancelled by user]". - // Don't re-finalize or overwrite with a generic failure. Also - // reconcile status from the actual container state — the cancel - // didn't touch the previously-running container, so leaving - // status='error' would lie about the project state. - if (run.abort.signal.aborted) { - await reconcileProjectStatusAfterInterrupt(project.id, project.container_id); - send("error", "cancelled by user"); - safeClose(); - return; - } - const rawMessage = e instanceof Error ? e.message : "Unknown error"; - // Redact any credentialed URLs Docker may have echoed into the - // error message before it lands in logs, stored stderr, or SSE. - const message = redactCredentialsInText(rawMessage); - console.error(`[run] FAILED: ${message}`); - run.appendStderr(`${message}\n`); - run.finalize(1); - setProjectRecordedStatus(project.id, "error", project.container_id); - for (const ev of buildErrorEvents(message)) send(ev.event, ev.data); - safeClose(); - return; - } - - // Container start is part of the same deploy run — operator's - // mental model is "rebuild" includes "and is now running." - try { - const envs = db - .query("SELECT key, value FROM env_vars WHERE project_id = ?") - .all(project.id) as { key: string; value: string }[]; - const ports = getProjectPorts(project.id); - - log("Starting container...\n"); - const containerId = await createAndStartContainer( - tag, - `moor-${project.name}`, - envs, - ports, - project.restart_policy, - { memoryLimitMb: project.memory_limit_mb, cpus: project.cpus }, - getProjectVolumes(project.id), - projectLabels(project.id, project.name), - { - command: parseStringArray(project.command), - entrypoint: parseStringArray(project.entrypoint), - files: getResolvedProjectFiles(project.id, envs), - }, - ); - console.log(`[run] container started: ${containerId}`); - - db.query("UPDATE projects SET container_id = ? WHERE id = ?").run(containerId, project.id); - setProjectRecordedStatus(project.id, "running", containerId); - - if (project.domain) { - await syncCaddyRoutes(); - log(`Route: ${project.domain} -> :${project.domain_port}\n`); - } - - run.finalize(0); - send("done", "Container started"); - } catch (e) { - setProjectRecordedStatus(project.id, "error", project.container_id); - const message = e instanceof Error ? e.message : "Unknown error"; - console.error(`[run] CONTAINER START FAILED: ${message}`); - run.appendStderr(`${message}\n`); - run.finalize(1); - send("error", message); - } - - safeClose(); - }, - cancel() { - clearInterval(keepalive); - streamClosed = true; - // If the client disconnects mid-build the build still runs to - // completion on the daemon and finalize() will fire from the build - // try/catch above. No need to finalize here. - }, - }); + const validationResponse = validateProjectGithubUrl(project); + if (validationResponse) return validationResponse; + return projectActionResultToResponse(await deployProject(project, { noCache })); +} - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); +function validateProjectGithubUrl(project: Project): Response | null { + if (!project.github_url) return null; + const urlError = validateGithubUrl(project.github_url); + if (!urlError) return null; + return errorResponse(urlError, 400); } /** #74 pure helper: shape the response body + status from a @@ -448,7 +162,7 @@ async function handleExec(req: Request, project: Project): Promise { // rather than a 503 "Docker unreachable" they can't act on. const body = (await req.json()) as { command?: string; timeout_ms?: number }; if (!body.command) { - return new Response("Missing command", { status: 400 }); + return errorResponse("Missing command", 400); } let timeout_ms: number | undefined; @@ -458,9 +172,9 @@ async function handleExec(req: Request, project: Project): Promise { body.timeout_ms < EXEC_TIMEOUT_MIN_MS || body.timeout_ms > EXEC_TIMEOUT_MAX_MS ) { - return new Response( + return errorResponse( `timeout_ms must be an integer between ${EXEC_TIMEOUT_MIN_MS} and ${EXEC_TIMEOUT_MAX_MS}`, - { status: 400 }, + 400, ); } timeout_ms = body.timeout_ms; @@ -504,164 +218,20 @@ async function handleExec(req: Request, project: Project): Promise { } const message = e instanceof Error ? e.message : "Unknown error"; console.error(`[exec] FAILED: ${message}`); - return new Response(message, { status: 500 }); + return errorResponse(message, 500); } } async function handleBuild(project: Project): Promise { - // #79: drain-mode gate. Builds are explicitly listed in the drain - // refusal scope — they're long-running work that an upgrade can't - // safely interleave with. - const drained = requireNotDraining(); - if (drained) return drained; - - console.log( - `[build] project=${project.name} github_url=${redactCredentials(project.github_url) ?? ""}`, - ); - if (!project.github_url) { - console.log("[build] rejected — no github_url"); - return new Response("No GitHub URL configured", { status: 400 }); - } - const urlError = validateGithubUrl(project.github_url); - if (urlError) return new Response(urlError, { status: 400 }); - - // Resolve source credential BEFORE any side effects (status flip, - // BuildRun row). Same contract as /run. - const resolved = resolveCredentialForBuild( - project.github_url, - project.source_credential_id ?? undefined, - ); - if (!resolved.ok) { - return resolverFailureResponse(resolved); - } - const { cloneUrl, used_credential_id } = resolved.value; - - const tag = `moor/${project.name}:latest`; - console.log( - `[build] tag=${tag} branch=${project.branch} dockerfile=${project.dockerfile} ` + - (used_credential_id !== null - ? `source_credential_id=${used_credential_id}` - : "anonymous-clone"), - ); - setProjectRecordedStatus(project.id, "building", project.container_id); - - // /build is the legacy non-SSE path used by api.projects.build in the web - // wrapper. We still wire it through BuildRun + buildImageStreaming so the - // row shape (started_at_ms, totals, exit_code, orphan-sweep eligibility) - // matches /run and moor_run_get can tail it mid-build. Returns when the - // build finishes, like the old contract. - const run = new BuildRun(project.id); - - try { - console.log("[build] starting docker build..."); - await buildImageStreaming( - cloneUrl, - project.branch, - project.dockerfile, - tag, - (line) => run.appendStdout(line), - false, - run.abort.signal, - ); - run.markStreamingDone(); - db.query("UPDATE projects SET image_tag = ? WHERE id = ?").run(tag, project.id); - setProjectRecordedStatus(project.id, "stopped", project.container_id); - - // Auto-detect exposed ports from image (always re-detect on rebuild) - await autoDetectPorts(project.id, tag, true); - - run.finalize(0); - console.log("[build] done — status set to 'stopped'"); - return Response.json({ message: "Build complete" }); - } catch (e) { - // #68: cancel already finalized as exit 130 with "[cancelled by user]"; - // don't overwrite with a generic failure. Reconcile status from the - // actual container state — the cancel didn't touch a running container. - if (run.abort.signal.aborted) { - await reconcileProjectStatusAfterInterrupt(project.id, project.container_id); - return new Response("cancelled by user", { status: 499 }); - } - setProjectRecordedStatus(project.id, "error", project.container_id); - const rawMessage = e instanceof Error ? e.message : "Unknown error"; - const message = redactCredentialsInText(rawMessage); - console.error(`[build] FAILED: ${message}`); - run.appendStderr(`${message}\n`); - run.finalize(1); - return buildErrorResponse(message); - } + const validationResponse = validateProjectGithubUrl(project); + if (validationResponse) return validationResponse; + return projectActionResultToResponse(await buildProject(project)); } async function handleStart(project: Project): Promise { - // #79: drain-mode gate. Starting a container is "new work" from - // moor's perspective — same gate as deploy/build. Stop/logs stay - // open so operators can quiesce things during drain. - const drained = requireNotDraining(); - if (drained) return drained; - - console.log(`[start] project=${project.name} image=${project.image_tag}`); - if (!project.image_tag) { - console.log("[start] rejected — no image built"); - return new Response("No image built yet", { status: 400 }); - } - - const envs = db.query("SELECT key, value FROM env_vars WHERE project_id = ?").all(project.id) as { - key: string; - value: string; - }[]; - const ports = getProjectPorts(project.id); - console.log( - `[start] creating container moor-${project.name} with ${envs.length} env vars and ${ports.length} ports`, - ); - - try { - const containerId = await createAndStartContainer( - project.image_tag, - `moor-${project.name}`, - envs, - ports, - project.restart_policy, - { memoryLimitMb: project.memory_limit_mb, cpus: project.cpus }, - getProjectVolumes(project.id), - projectLabels(project.id, project.name), - { - command: parseStringArray(project.command), - entrypoint: parseStringArray(project.entrypoint), - files: getResolvedProjectFiles(project.id, envs), - }, - ); - console.log(`[start] container started: ${containerId}`); - db.query("UPDATE projects SET container_id = ? WHERE id = ?").run(containerId, project.id); - setProjectRecordedStatus(project.id, "running", containerId); - - if (project.domain) { - await syncCaddyRoutes(); - } - - return Response.json({ message: "Container started" }); - } catch (e) { - setProjectRecordedStatus(project.id, "error", project.container_id); - const message = e instanceof Error ? e.message : "Unknown error"; - console.error(`[start] FAILED: ${message}`); - return new Response(message, { status: 500 }); - } + return projectActionResultToResponse(await startProject(project)); } async function handleStop(project: Project): Promise { - console.log(`[stop] project=${project.name} container=${project.container_id}`); - if (!project.container_id) { - console.log("[stop] no container — marking as stopped"); - setProjectRecordedStatus(project.id, "stopped", project.container_id); - return Response.json({ message: "Container stopped" }); - } - - try { - await stopContainer(project.container_id); - console.log("[stop] container stopped"); - } catch (e) { - const message = e instanceof Error ? e.message : "Unknown error"; - console.error(`[stop] error during stop (marking as stopped anyway): ${message}`); - } - - setProjectRecordedStatus(project.id, "stopped", project.container_id); - return Response.json({ message: "Container stopped" }); + return projectActionResultToResponse(await stopProject(project)); } diff --git a/apps/api/routes/exec.test.ts b/apps/api/routes/exec.test.ts index dfd4991..8623fb8 100644 --- a/apps/api/routes/exec.test.ts +++ b/apps/api/routes/exec.test.ts @@ -14,6 +14,10 @@ import { beforeEach, describe, expect, test } from "bun:test"; const { default: db } = await import("../db"); const { handleExec } = await import("./exec"); +async function errorMessage(res: Response): Promise { + return ((await res.json()) as { error: string }).error; +} + async function call(method: string, path: string, body?: unknown): Promise { const req = new Request(`http://localhost${path}`, { method, @@ -42,7 +46,7 @@ describe("#73 POST /api/projects/:id/exec/async live-check wiring", () => { command: "echo hi", }); expect(res.status).toBe(400); - expect(await res.text()).toBe("Project has no container; build/start it first"); + expect(await errorMessage(res)).toBe("Project has no container; build/start it first"); }); test("input validation (bad timeout_ms) still fires before the live check", async () => { @@ -59,6 +63,6 @@ describe("#73 POST /api/projects/:id/exec/async live-check wiring", () => { timeout_ms: 500, // below the min }); expect(res.status).toBe(400); - expect(await res.text()).toContain("timeout_ms must be an integer between"); + expect(await errorMessage(res)).toContain("timeout_ms must be an integer between"); }); }); diff --git a/apps/api/routes/exec.ts b/apps/api/routes/exec.ts index a123927..59f51f8 100644 --- a/apps/api/routes/exec.ts +++ b/apps/api/routes/exec.ts @@ -8,6 +8,7 @@ import { startAsyncExec, stopAsyncExec, } from "../exec-async"; +import { errorResponse } from "../http"; import { liveRequireErrorResponse, requireLiveContainer } from "../status-reconciler"; type Project = { id: number; container_id: string | null; status: string }; @@ -25,12 +26,12 @@ export async function handleExec(req: Request, url: URL): Promise EXEC_ASYNC_TIMEOUT_MAX_MS ) { - return new Response( + return errorResponse( `timeout_ms must be an integer between ${EXEC_ASYNC_TIMEOUT_MIN_MS} and ${EXEC_ASYNC_TIMEOUT_MAX_MS}`, - { status: 400 }, + 400, ); } timeoutMs = body.timeout_ms; @@ -71,7 +72,7 @@ export async function handleExec(req: Request, url: URL): Promise { + return ((await res.json()) as { error: string }).error; +} + function insertProject(name: string, github_url: string | null): { id: number } { return db .query( @@ -148,7 +152,7 @@ describe("#30 project URL credential redaction", () => { memory_limit_mb: 4, }); expect(res.status).toBe(400); - expect(await res.text()).toContain("memory_limit_mb must be >="); + expect(await errorMessage(res)).toContain("memory_limit_mb must be >="); }); test("POST rejects cpus <= 0 (null is the clear signal)", async () => { @@ -158,7 +162,7 @@ describe("#30 project URL credential redaction", () => { cpus: 0, }); expect(res.status).toBe(400); - expect(await res.text()).toContain("cpus must be > 0"); + expect(await errorMessage(res)).toContain("cpus must be > 0"); }); test("POST accepts and persists valid memory_limit_mb and cpus", async () => { @@ -524,7 +528,7 @@ describe("command/entrypoint override on projects", () => { command: "tunnel run", }); expect(res.status).toBe(400); - expect(await res.text()).toContain("command must be an array"); + expect(await errorMessage(res)).toContain("command must be an array"); }); test("PUT sets command, and [] or null clears it back to the image default", async () => { diff --git a/apps/api/routes/projects.ts b/apps/api/routes/projects.ts index 06fe9f7..d02b536 100644 --- a/apps/api/routes/projects.ts +++ b/apps/api/routes/projects.ts @@ -2,6 +2,7 @@ import { syncCaddyRoutes } from "../caddy"; import { parseStringArray, serializeStringArray, validateStringArray } from "../container-config"; import db from "../db"; import { removeContainer, removeVolume, stopContainer } from "../docker"; +import { errorResponse, responseErrorMessage } from "../http"; import { reconcileGithubUrl, redactCredentials, serializeProject } from "../redact"; import { validateCpus, validateMemoryLimitMb } from "../resource-limits"; import { collectProjectVolumeDockerNames } from "./volumes"; @@ -47,10 +48,10 @@ async function applyCaddySync(action: string): Promise { return null; } catch (e) { const msg = e instanceof Error ? e.message : String(e); - return new Response( + return errorResponse( `${action} saved, but Caddy route apply failed: ${msg}\n` + "Manual recovery: docker compose exec caddy caddy reload --config /app/data/Caddyfile --adapter caddyfile", - { status: 500 }, + 500, ); } } @@ -74,7 +75,7 @@ export async function handleProjects(req: Request, url: URL): Promise 0) { const messages: string[] = []; if (caddyFailure) { - messages.push(`Caddy reload failed (${await caddyFailure.text()})`); + messages.push(`Caddy reload failed (${await responseErrorMessage(caddyFailure)})`); } if (purgeFailures.length > 0) { messages.push( @@ -189,33 +190,31 @@ async function handleCreate(req: Request): Promise { console.log( `[projects] create: name=${name} github_url=${redactCredentials(github_url) ?? ""} docker_image=${docker_image} branch=${branch || "main"} dockerfile=${dockerfile || "Dockerfile"} domain=${domain || ""} domain_port=${domain_port || ""} memory_limit_mb=${memory_limit_mb ?? ""} cpus=${cpus ?? ""} source_credential_id=${source_credential_id ?? ""}`, ); - if (!name) return new Response("name is required", { status: 400 }); + if (!name) return errorResponse("name is required", 400); if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) { - return new Response("name must be alphanumeric (hyphens and underscores allowed)", { - status: 400, - }); + return errorResponse("name must be alphanumeric (hyphens and underscores allowed)", 400); } const memErr = validateMemoryLimitMb(memory_limit_mb); - if (memErr) return new Response(memErr, { status: 400 }); + if (memErr) return errorResponse(memErr, 400); const cpuErr = validateCpus(cpus); - if (cpuErr) return new Response(cpuErr, { status: 400 }); + if (cpuErr) return errorResponse(cpuErr, 400); const cmdErr = validateStringArray(command, "command"); - if (cmdErr) return new Response(cmdErr, { status: 400 }); + if (cmdErr) return errorResponse(cmdErr, 400); const epErr = validateStringArray(entrypoint, "entrypoint"); - if (epErr) return new Response(epErr, { status: 400 }); + if (epErr) return errorResponse(epErr, 400); // docker_image projects cannot pin a source credential; the id is // about to be force-nulled regardless of input. Skip validation so // a caller mixing docker_image + a stale id doesn't get a 400 for // a field that's being ignored anyway. if (!docker_image) { const credErr = validateSourceCredentialId(source_credential_id); - if (credErr) return new Response(credErr, { status: 400 }); + if (credErr) return errorResponse(credErr, 400); } const existing = db.query("SELECT id FROM projects WHERE name = ?").get(name); if (existing) { - return new Response("A project with this name already exists", { status: 409 }); + return errorResponse("A project with this name already exists", 409); } const validPolicies = ["no", "on-failure", "always", "unless-stopped"]; @@ -265,19 +264,19 @@ async function handleUpdate(req: Request, id: number): Promise { if ("memory_limit_mb" in body) { const err = validateMemoryLimitMb(body.memory_limit_mb); - if (err) return new Response(err, { status: 400 }); + if (err) return errorResponse(err, 400); } if ("cpus" in body) { const err = validateCpus(body.cpus); - if (err) return new Response(err, { status: 400 }); + if (err) return errorResponse(err, 400); } if ("command" in body) { const err = validateStringArray(body.command, "command"); - if (err) return new Response(err, { status: 400 }); + if (err) return errorResponse(err, 400); } if ("entrypoint" in body) { const err = validateStringArray(body.entrypoint, "entrypoint"); - if (err) return new Response(err, { status: 400 }); + if (err) return errorResponse(err, 400); } // Skip credential validation when the update switches to docker_image: // source_credential_id is about to be force-cleared on this row, so a @@ -285,7 +284,7 @@ async function handleUpdate(req: Request, id: number): Promise { const switchingToDockerImage = "docker_image" in body && !!body.docker_image; if ("source_credential_id" in body && !switchingToDockerImage) { const err = validateSourceCredentialId(body.source_credential_id); - if (err) return new Response(err, { status: 400 }); + if (err) return errorResponse(err, 400); } // Reconciliation: if the incoming github_url matches the redacted form of the @@ -373,10 +372,10 @@ async function handleUpdate(req: Request, id: number): Promise { const current = db.query("SELECT * FROM projects WHERE id = ?").get(id) as { github_url: string | null; } | null; - if (!current) return new Response("Not found", { status: 404 }); + if (!current) return errorResponse("Not found", 404); return Response.json(presentProject(current)); } - return new Response("No fields to update", { status: 400 }); + return errorResponse("No fields to update", 400); } if ("name" in body && body.name) { @@ -384,7 +383,7 @@ async function handleUpdate(req: Request, id: number): Promise { .query("SELECT id FROM projects WHERE name = ? AND id != ?") .get(body.name, id); if (existing) { - return new Response("A project with this name already exists", { status: 409 }); + return errorResponse("A project with this name already exists", 409); } } @@ -393,7 +392,7 @@ async function handleUpdate(req: Request, id: number): Promise { .query(`UPDATE projects SET ${fields.join(", ")} WHERE id = ? RETURNING *`) .get(...values); - if (!row) return new Response("Not found", { status: 404 }); + if (!row) return errorResponse("Not found", 404); // Sync Caddy if domain-related fields changed if ("domain" in body || "domain_port" in body) { diff --git a/apps/api/routes/registry-credentials.ts b/apps/api/routes/registry-credentials.ts index c9f33e2..2353748 100644 --- a/apps/api/routes/registry-credentials.ts +++ b/apps/api/routes/registry-credentials.ts @@ -10,6 +10,7 @@ // `ghcr.io/owner/img`) and no whitespace. Without this, an operator // could store a credential that the pull-path lookup would never find. +import { errorResponse, readJsonObject } from "../http"; import { type CredentialMetadata, createCredential, @@ -30,13 +31,13 @@ export async function handleRegistryCredentials(req: Request, url: URL): Promise if (req.method === "GET") return handleGet(id); if (req.method === "PUT") return handleUpdate(req, id); if (req.method === "DELETE") return handleDelete(id); - return new Response("Method Not Allowed", { status: 405 }); + return errorResponse("Method Not Allowed", 405); } if (COLLECTION.test(url.pathname)) { if (req.method === "GET") return handleList(); if (req.method === "POST") return handleCreate(req); - return new Response("Method Not Allowed", { status: 405 }); + return errorResponse("Method Not Allowed", 405); } return null; @@ -90,28 +91,6 @@ function handleDelete(id: number): Response { return new Response(null, { status: 204 }); } -type JsonObjectOk = { ok: true; value: Record }; -type JsonObjectErr = { ok: false; response: Response }; - -async function readJsonObject(req: Request): Promise { - let raw: unknown; - try { - raw = await req.json(); - } catch { - return { - ok: false, - response: Response.json({ error: "invalid JSON body" }, { status: 400 }), - }; - } - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return { - ok: false, - response: Response.json({ error: "request body must be a JSON object" }, { status: 400 }), - }; - } - return { ok: true, value: raw as Record }; -} - type CreateOk = { ok: true; value: { hostname: string; username: string; secret: string }; diff --git a/apps/api/routes/runs.ts b/apps/api/routes/runs.ts index 321a21a..b266c2a 100644 --- a/apps/api/routes/runs.ts +++ b/apps/api/routes/runs.ts @@ -1,6 +1,7 @@ import { activeBuildRuns } from "../build-runs"; import { stopCronRun } from "../cron"; import db from "../db"; +import { errorResponse } from "../http"; const PAGE_SIZE = 20; @@ -85,7 +86,7 @@ export async function handleRuns(req: Request, url: URL): Promise }; -type JsonObjectErr = { ok: false; response: Response }; - -async function readJsonObject(req: Request): Promise { - let raw: unknown; - try { - raw = await req.json(); - } catch { - return { - ok: false, - response: Response.json({ error: "invalid JSON body" }, { status: 400 }), - }; - } - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return { - ok: false, - response: Response.json({ error: "request body must be a JSON object" }, { status: 400 }), - }; - } - return { ok: true, value: raw as Record }; -} - type CreateOk = { ok: true; value: { diff --git a/apps/api/routes/volumes.test.ts b/apps/api/routes/volumes.test.ts index a8c5fde..00c9df1 100644 --- a/apps/api/routes/volumes.test.ts +++ b/apps/api/routes/volumes.test.ts @@ -23,6 +23,10 @@ async function call(method: string, path: string, body?: unknown): Promise { + return ((await res.json()) as { error: string }).error; +} + function makeProject(name: string): number { const row = db.query("INSERT INTO projects (name) VALUES (?) RETURNING id").get(name) as { id: number; @@ -76,7 +80,7 @@ describe("#35 volume routes", () => { target: "/proc/1", }); expect(badTarget.status).toBe(400); - expect(await badTarget.text()).toContain("/proc/"); + expect(await errorMessage(badTarget)).toContain("/proc/"); }); test("POST 409 on duplicate name within a project", async () => { @@ -84,7 +88,7 @@ describe("#35 volume routes", () => { await call("POST", `/api/projects/${pid}/volumes`, { name: "data", target: "/x" }); const res = await call("POST", `/api/projects/${pid}/volumes`, { name: "data", target: "/y" }); expect(res.status).toBe(409); - expect(await res.text()).toContain("already has a volume named"); + expect(await errorMessage(res)).toContain("already has a volume named"); }); test("POST 409 on duplicate target within a project", async () => { @@ -92,7 +96,7 @@ describe("#35 volume routes", () => { await call("POST", `/api/projects/${pid}/volumes`, { name: "a", target: "/data" }); const res = await call("POST", `/api/projects/${pid}/volumes`, { name: "b", target: "/data" }); expect(res.status).toBe(409); - expect(await res.text()).toContain("already has a volume mounted at"); + expect(await errorMessage(res)).toContain("already has a volume mounted at"); }); test("two different projects can have the same logical name (different docker_name)", async () => { diff --git a/apps/api/routes/volumes.ts b/apps/api/routes/volumes.ts index d735df5..28a8de3 100644 --- a/apps/api/routes/volumes.ts +++ b/apps/api/routes/volumes.ts @@ -1,4 +1,5 @@ import db from "../db"; +import { errorResponse } from "../http"; import { buildDockerName, validateDockerName, @@ -25,7 +26,7 @@ export async function handleVolumes(req: Request, url: URL): Promise; - has_gap: boolean; - }; -}; - -// #138: live per-project container stats (single Docker snapshot). running=false -// (stopped / never started) comes back with zeroed counters, not a 404. -export type ContainerStats = { - running: boolean; - cpu_percent: number; - memory_bytes: number; - memory_limit_bytes: number; - memory_percent: number; - network_rx_bytes: number; - network_tx_bytes: number; - block_read_bytes: number; - block_write_bytes: number; - pids: number; -}; +export type { + ContainerStats, + Cron, + EnvVar, + PortMapping, + Project, + ProjectHistory, + Run, + TerminalSession, +} from "@moor-sh/contract"; async function request(path: string, opts?: RequestInit): Promise { const res = await fetch(path, { @@ -128,8 +41,7 @@ async function request(path: string, opts?: RequestInit): Promise { throw new Error("Unauthorized"); } if (!res.ok) { - const body = await res.text(); - throw new Error(`${res.status}: ${body}`); + throw new Error(`${res.status}: ${await readErrorMessage(res)}`); } if (res.status === 204) return undefined as T; return res.json(); @@ -157,9 +69,9 @@ export const api = { projects: { list: () => request("/api/projects"), get: (id: number) => request(`/api/projects/${id}`), - create: (data: Partial) => + create: (data: CreateProjectRequest) => request("/api/projects", { method: "POST", body: JSON.stringify(data) }), - update: (id: number, data: Partial) => + update: (id: number, data: UpdateProjectRequest) => request(`/api/projects/${id}`, { method: "PUT", body: JSON.stringify(data) }), delete: (id: number) => request(`/api/projects/${id}`, { method: "DELETE" }), build: (id: number) => @@ -190,7 +102,7 @@ export const api = { return; } if (!res.ok || !res.body) { - onError(await res.text()); + onError(await readErrorMessage(res)); return; } const reader = res.body.getReader(); @@ -214,25 +126,22 @@ export const api = { } }, logs: (id: number, since?: number) => - request<{ logs: string; lastTimestamp: number }>( - `/api/projects/${id}/logs${since ? `?since=${since}` : ""}`, - ), + request(`/api/projects/${id}/logs${since ? `?since=${since}` : ""}`), exec: (id: number, command: string) => - request<{ exitCode: number; stdout: string; stderr: string }>(`/api/projects/${id}/exec`, { + request(`/api/projects/${id}/exec`, { method: "POST", body: JSON.stringify({ command }), }), - buildOutput: (id: number) => - request(`/api/projects/${id}/build-output`), + buildOutput: (id: number) => request(`/api/projects/${id}/build-output`), }, crons: { list: (projectId: number) => request(`/api/projects/${projectId}/crons`), - create: (projectId: number, data: Partial) => + create: (projectId: number, data: CreateCronRequest) => request(`/api/projects/${projectId}/crons`, { method: "POST", body: JSON.stringify(data), }), - update: (id: number, data: Partial) => + update: (id: number, data: UpdateCronRequest) => request(`/api/crons/${id}`, { method: "PUT", body: JSON.stringify(data) }), delete: (id: number) => request(`/api/crons/${id}`, { method: "DELETE" }), run: (id: number) => request<{ ok: boolean }>(`/api/crons/${id}/run`, { method: "POST" }), @@ -242,7 +151,7 @@ export const api = { }, envs: { list: (projectId: number) => request(`/api/projects/${projectId}/envs`), - set: (projectId: number, vars: { key: string; value: string }[]) => + set: (projectId: number, vars: SetEnvVarsRequest) => request(`/api/projects/${projectId}/envs`, { method: "PUT", body: JSON.stringify(vars), @@ -260,28 +169,23 @@ export const api = { }), }, server: { - stats: () => - request<{ - hostname: string; - os: string; - uptime: string; - cpu: { percent: number; cores: number }; - memory: { total: string; used: string; percent: number }; - disk: { total: string; used: string; percent: number }; - disks?: { mount: string; total: string; used: string; percent: number; label?: string }[]; - containers: { running: number; total: number }; - }>("/api/server/stats"), + stats: () => request("/api/server/stats"), }, runs: { list: (projectId: number, page = 1) => - request<{ runs: Run[]; total: number }>(`/api/projects/${projectId}/runs?page=${page}`), + request(`/api/projects/${projectId}/runs?page=${page}`), get: (id: number) => request(`/api/runs/${id}`), stop: (id: number) => request<{ ok: boolean }>(`/api/runs/${id}/stop`, { method: "POST" }), }, terminalSessions: { list: (projectId: number) => - request<{ sessions: TerminalSession[] }>(`/api/projects/${projectId}/terminal-sessions`), + request(`/api/projects/${projectId}/terminal-sessions`), kill: (execId: string) => request<{ ok: boolean }>(`/api/terminal-sessions/${execId}/kill`, { method: "POST" }), }, }; + +async function readErrorMessage(res: Response): Promise { + const text = await res.text(); + return parseErrorMessage(text, res.status); +} diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 22b259e..3f188aa 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -4,6 +4,10 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@moor-sh/contract": ["../../packages/contract/src/index.ts"] + }, "jsx": "react-jsx", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, diff --git a/biome.json b/biome.json index 910811d..94aaf5e 100644 --- a/biome.json +++ b/biome.json @@ -24,7 +24,9 @@ "apps/respawner/**/*.ts", "apps/web/src/**/*.{ts,tsx,css}", "apps/web/vite.config.ts", - "packages/**/*.{ts,tsx}" + "packages/**/*.{ts,tsx}", + "packages/mcp/scripts/**/*.ts", + "scripts/**/*.js" ] } } diff --git a/bun.lock b/bun.lock index 32efa1e..5e416be 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "semantic-release": "^25.0.3", "semantic-release-ai-notes": "^0.2.3", "semantic-release-monorepo": "^8.0.2", + "semantic-release-plugin-decorators": "^4.0.0", "zod": "^4.3.6", }, }, @@ -30,6 +31,7 @@ "name": "@moor/web", "version": "0.1.0", "dependencies": { + "@moor-sh/contract": "workspace:*", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "react": "^19.2.4", @@ -51,11 +53,15 @@ "moor": "src/index.ts", }, }, + "packages/contract": { + "name": "@moor-sh/contract", + "version": "0.0.0-development", + }, "packages/mcp": { "name": "@moor-sh/mcp", "version": "0.0.0-development", "bin": { - "moor-mcp": "src/index.ts", + "moor-mcp": "bin/moor-mcp.js", }, "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -233,6 +239,8 @@ "@moor-sh/cli": ["@moor-sh/cli@workspace:packages/cli"], + "@moor-sh/contract": ["@moor-sh/contract@workspace:packages/contract"], + "@moor-sh/mcp": ["@moor-sh/mcp@workspace:packages/mcp"], "@moor/api": ["@moor/api@workspace:apps/api"], diff --git a/docs/agent-workflow.md b/docs/agent-workflow.md new file mode 100644 index 0000000..e41fd5d --- /dev/null +++ b/docs/agent-workflow.md @@ -0,0 +1,63 @@ +# Recommended agent workflow + +The moor MCP server registers over 50 tools. An agent does not need all of them. Most work runs through a small core loop: deploy, watch, act, adjust. This doc is the mental model. The full tool reference lives in [`packages/mcp/README.md`](../packages/mcp/README.md). + +## The core loop + +Ten tools cover the day-to-day. + +| Tool | Use it to | +| --- | --- | +| `moor_status` | List every project with its recorded and live status. The starting point. | +| `moor_project_get` | Read one project's full record and confirm `live_status` after an action. | +| `moor_deploy` | Create or update a project end to end (metadata, env, build/run) in one call. | +| `moor_logs` | Read recent container logs. First stop when a container misbehaves. | +| `moor_exec` | Run a command inside a running container. | +| `moor_env_set` | Set env vars. Restarts the container so the change takes effect. | +| `moor_rebuild` | Rebuild from source (git pull + docker build) and restart. For code changes. | +| `moor_restart` | Recreate from the existing image, no build. For env, port, volume, or limit changes, or to recover a crashed container. | +| `moor_runs` | List build and cron run history for a project. | +| `moor_run_get` | Fetch one run with its stdout and stderr. | + +The rebuild-versus-restart split is the one distinction worth internalizing. `moor_rebuild` produces a new image and is slow. `moor_restart` reuses the current image and is fast. Reach for `moor_restart` unless the code or Dockerfile changed. + +## A worked flow + +Deploy a project from a repo, confirm it, debug it, and ship a fix. + +``` +1. moor_deploy({ name: "scraper", github_url: "https://github.com/me/scraper", + env: { API_KEY: "..." }, run: true }) + → builds, starts, returns the build output + +2. moor_project_get({ project: "scraper" }) + → live_status: "running" // deploy confirmed + +3. moor_logs({ project: "scraper" }) + → app is crash-looping on a missing config value + +4. moor_env_set({ project: "scraper", env: { TIMEOUT_MS: "30000" } }) + → sets the var and restarts the container + +5. moor_logs({ project: "scraper" }) + → still failing; the bug is in the code, not the config + +6. moor_rebuild({ project: "scraper" }) // after pushing the fix + → pulls, rebuilds, restarts, returns the build output + +7. moor_runs({ project: "scraper" }) then moor_run_get({ run_id }) + → inspect the build output if the rebuild failed +``` + +That is the whole loop for most tasks. Deploy, check `live_status`, read logs, adjust env or exec into the container, and rebuild or restart depending on what changed. + +## Beyond the core + +Reach past the core loop when a task calls for it. These are the common next steps; see [`packages/mcp/README.md`](../packages/mcp/README.md) for the full set. + +- **Long-running commands:** `moor_exec_async`, `moor_exec_status`, `moor_exec_stop` for work that outlasts a single `moor_exec` call. +- **Cron:** `moor_cron_create`, `moor_cron_update`, `moor_cron_delete`, `moor_cron_run` to schedule and trigger jobs inside a container. +- **Persistent data and config:** `moor_volume_add` / `moor_volume_list` / `moor_volume_remove` for named volumes, and `moor_file_set` / `moor_file_list` / `moor_file_remove` to inject files. +- **Private sources:** the `moor_registry_credential_*` and `moor_source_credential_*` tools for private registries and repos. Run `moor_source_credential_check` before deploying a private repo. +- **Host and observability:** `moor_stats` for host CPU / memory / disk, `moor_project_stats` for live per-container usage, and `moor_project_history` for stored history. +- **Host upkeep:** `moor_drain_enable` / `moor_drain_status`, `moor_update_apply` / `moor_update_status`, `moor_db_backup`, and `moor_cleanup_plan` / `moor_cleanup_execute`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..266105e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,59 @@ +# Architecture + +This doc is the map: what moor does, how the pieces fit, and where a new feature's code belongs. It is written for a contributor who wants to know where a change lives before writing it. For scope and non-goals, read [`brand.md`](../brand.md); this doc does not repeat them. + +## Surface hierarchy + +Moor has one product with four ways in. They are not peers. + +1. **HTTP API (`apps/api`) is the source of truth.** Every capability is an API route. It owns the SQLite state, the Docker socket, and all business logic. Nothing else in the repo does work the API cannot do. +2. **MCP (`packages/mcp`) is the complete agent and operator surface.** It mirrors the full API: every lane below has MCP tools, including the API-only ones the web UI never shows. An agent driving moor should be able to do anything through MCP. When you add an API capability, add the matching MCP tool. +3. **Web UI (`apps/web`) is a monitoring and happy-path console, partial by design.** It covers projects, builds, logs, terminal, env, cron, ports, and domains. Volumes, file injection, command/entrypoint overrides, credentials, drain, self-update, backups, and cleanup are deliberately not in the UI. Do not treat UI parity as a goal. +4. **CLI (`packages/cli`) is a small utility surface and stays small.** A handful of one-shot commands (status, logs, rebuild, restart, exec, env, stats, history) plus MCP config generation. It is a convenience layer, not a second full client. Resist growing it toward API parity. + +## Lanes + +Capabilities group into four lanes. This is the vocabulary to use when deciding where a feature goes. + +**Deploy** is the core: a project deployed from GitHub source or a registry image, then everything that shapes how its container runs. Env vars, published ports, named volumes, injected files, command/entrypoint overrides, build (git pull + docker build) and run (recreate from image), and streaming build and container logs. This is most of the surface area and most of the day-to-day loop (see [`agent-workflow.md`](agent-workflow.md)). + +**Observe** is read-only visibility: host stats (CPU, memory, disk), live per-container stats, stored per-minute resource history, and lifecycle events from the Docker event stream. It answers "what is happening" and "what happened," without changing anything. + +**Operate** is host upkeep: auth (admin password plus `MOOR_API_KEY` bearer), DB backups, drain mode, dangling-image cleanup, and self-update via a transient respawner container. These keep a single unattended server healthy over time. + +**Agent surface and UI** are the ways in from the hierarchy above: MCP and CLI for programmatic and agent use, the web console for eyes-on monitoring. + +## Decision: operate is core, not an add-on + +Self-update, drain, backup, and cleanup are core agent-ops features, not conveniences bolted onto a container manager. Moor's point is agent-managed deployment on a single bare-metal server, and a server an agent runs unattended has to maintain itself: update its own image, drain before maintenance, snapshot its DB before a risky change, reclaim disk that builds leak. Without these, "never SSH in to maintain it" breaks the first time the host needs upkeep, and the agent story falls back to a human on a terminal. + +There is a real tension here, recorded on purpose. `brand.md` scopes moor as "one server, a thin interface over Docker." Self-update is the least thin subsystem in the codebase: it spawns a separate respawner container that replays the operator's Compose stack, retags images, polls health, and rolls back. We accept that weight because never-SSH-to-maintain is central to the agent-operated story, and the alternative (an agent that can deploy but not keep its own host alive) fails the promise. The subsystem stays quarantined in `apps/respawner` plus the `update-*` modules so the rest of the API stays thin. + +## Map: apps and packages + +**apps** +- `api`: the HTTP API. Source of truth: SQLite state, Docker socket, all business logic. Route handlers in `routes/`, domain logic in the top-level modules beside them. +- `web`: React + Vite admin console. The monitoring and happy-path UI. +- `respawner`: transient container that performs a self-update (pull, retag, `compose up`, health-check, rollback) and then exits. No daemon, no open ports. +- `site`: the static marketing/install site (`moor.sh`), including the install script. + +**packages** +- `contract`: shared TypeScript types, a thin `fetch`-based API client, and request validators. The typed contract between the API and its clients; consumed by `web`, `mcp`, and `cli`. +- `mcp`: the MCP server. The complete agent/operator surface, one tool module per lane under `src/tools/` (projects, env, exec, runs, cleanup, credentials, server, update, context). +- `cli`: the small CLI utility. One file per command under `src/commands/`. + +## Where a new feature goes + +Start in the API; everything else follows from it. + +1. **Add the capability to `apps/api`.** New route handler in `apps/api/routes/`, domain logic in a sibling module, schema migrations in `db-migrations.ts`. This is non-optional: if it is not in the API, it does not exist. +2. **Add types, client changes, and validators to `packages/contract`** so clients share one definition. +3. **Add the matching MCP tool** in the lane's module under `packages/mcp/src/tools/`. MCP mirrors the full API, so this is expected for every capability, not just user-facing ones. +4. **Add web UI only if it belongs to the happy path** (deploy/observe basics). Operate-lane and advanced deploy features stay API/MCP-only by design; do not add UI for them without a reason. +5. **Add a CLI command only if it is a common one-shot** an operator wants from a shell. The default is no: the CLI stays small. + +By lane: deploy and observe features land in the project/stats/history/runs API modules and their MCP counterparts, and usually get UI. Operate features land in the `drain`, `db-backup`, `cleanup`, and `update-*` API modules plus `respawner`, with MCP tools but no UI. + +## Acceptance bar + +A new contributor should be able to answer, before writing code: which lane is this, does it belong in the API (always yes), which MCP tool module gets it, and does it earn a place in the web UI or CLI (usually no). If those answers are clear from this doc, it has done its job. diff --git a/docs/self-hosting.md b/docs/self-hosting.md index a0ff656..7c364e6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -4,6 +4,8 @@ Operating moor on your own server: first-boot password, admin access, API key li The [root README](../README.md) covers the basic install. This doc covers everything else. +Some of what follows is not in the web UI. The admin UI covers projects, builds, logs, the terminal, env vars, cron, ports, and domains. Project volumes, file injection, command/entrypoint overrides, registry and source credentials, drain mode, self-update, DB backups, and image cleanup are API and MCP only. Use `curl` with `MOOR_API_KEY` or the matching MCP tool for those. + ## First boot The installer generates a random `MOOR_INITIAL_PASSWORD` into `.env` and prints it once. Moor uses that value on first start to create the admin user. @@ -273,6 +275,106 @@ The v1 surface is intentionally narrow: - **Private base images inside the Dockerfile.** Same as the registry section above: `X-Registry-Config` on `/build` is not wired. - **Submodules, Git LFS, GitHub App tokens.** PATs only. +## Project volumes + +A project volume is a named Docker volume mounted into a project's container at a fixed path. Use it for data that must survive a rebuild: a database directory, an upload store, a cache. Moor stores the mount config (a logical name, the in-container target, and a generated Docker volume name of the form `moor--`) and hands the binding to Docker on container create. The Docker volume itself is created lazily on first container start. + +Mounts apply on the next container recreate (`moor_rebuild` / `moor_restart` / `moor_deploy` / a `moor_project` run). An already-running container keeps its existing mounts until it is recreated. + +Volumes are API and MCP only. The HTTP API requires `MOOR_API_KEY` (see [API keys](#api-keys) above) and takes a numeric project id; the MCP tools accept a project name or id. + +### List, add, remove + +```bash +KEY=$(grep '^MOOR_API_KEY=' .env | cut -d= -f2-) + +# List volumes on project id=1 +curl -fsS -H "Authorization: Bearer $KEY" \ + http://127.0.0.1:3000/api/projects/1/volumes + +# Attach a volume: logical name + absolute in-container target +curl -fsS -X POST http://127.0.0.1:3000/api/projects/1/volumes \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"name":"pgdata","target":"/var/lib/postgresql/data"}' + +# Remove the mount config for volume id=3 (the Docker volume and its data are preserved) +curl -fsS -X DELETE http://127.0.0.1:3000/api/projects/1/volumes/3 \ + -H "Authorization: Bearer $KEY" +``` + +The logical `name` is unique per project (alphanumeric, `_`, `-`). The `target` must be an absolute path and cannot mount over `/`, `/proc`, `/sys`, or `/dev`. + +Removing a mount detaches it from the project config only. The underlying Docker volume and its data are kept on purpose, so a recreated project can remount them. To actually delete the data, use `moor_project_delete` with `purge_volumes: true`, or run `docker volume rm ` on the host. + +MCP equivalents: `moor_volume_list`, `moor_volume_add`, `moor_volume_remove`. + +## File injection + +A project can declare files to write into its container without a Dockerfile or SSH access. Moor writes each file through a tar archive `PUT` right before the container starts, on every recreate, honoring an octal mode. This is the path for a config file or a TLS cert that a stock image expects on disk. + +Each file is identified by its destination path. Setting the same path again updates its content or mode rather than adding a duplicate. Provide exactly one of `content` (inline) or `env_ref` (the name of a project env var to source the content from at create time, so a secret stays in the env store instead of plaintext in the file config). Inline content is capped at 1 MiB. + +Files apply on the next container recreate. File injection is API and MCP only; the HTTP API requires `MOOR_API_KEY` and a numeric project id. + +### List, set, remove + +```bash +KEY=$(grep '^MOOR_API_KEY=' .env | cut -d= -f2-) + +# List files on project id=1 (raw inline content is never returned) +curl -fsS -H "Authorization: Bearer $KEY" \ + http://127.0.0.1:3000/api/projects/1/files + +# Inline file with an explicit mode +curl -fsS -X POST http://127.0.0.1:3000/api/projects/1/files \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"path":"/etc/app/config.yaml","content":"log_level: info\n","mode":"0644"}' + +# File sourced from an env var (keeps the secret in the env store) +curl -fsS -X POST http://127.0.0.1:3000/api/projects/1/files \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"path":"/etc/ssl/cert.pem","env_ref":"TLS_CERT","mode":"0600"}' + +# Remove file spec id=2 +curl -fsS -X DELETE http://127.0.0.1:3000/api/projects/1/files/2 \ + -H "Authorization: Bearer $KEY" +``` + +`path` must be absolute, printable ASCII with no whitespace, and cannot target `/`, `/proc`, `/sys`, or `/dev`. `mode` is an octal string like `0644`, `600`, or `0600`; it defaults to `0644` when omitted. An `env_ref` that names a var not set on the project fails the container create, so set the env var first. + +MCP equivalents: `moor_file_set`, `moor_file_list`, `moor_file_remove`. + +## Command and entrypoint overrides + +A project can override the image's default command (Docker `Cmd`) and entrypoint (Docker `Entrypoint`), each as an argv array of strings. This lets a stock image run a custom command with no throwaway Dockerfile. The motivating case is running `cloudflare/cloudflared` with command `["tunnel","run"]`, a `TUNNEL_TOKEN` env var, and an injected credentials file, all declaratively. + +Both are set on the project record. Omit a field to keep the image default; pass `[]` or `null` to clear a previously-set override and return to the image default. Overrides apply on the next container recreate. + +Command and entrypoint are set through the same project create/update calls, not a dedicated endpoint. They are API and MCP only; the web UI does not surface them. + +```bash +KEY=$(grep '^MOOR_API_KEY=' .env | cut -d= -f2-) + +# On create: POST /api/projects with command/entrypoint arrays +curl -fsS -X POST http://127.0.0.1:3000/api/projects \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"name":"tunnel","docker_image":"cloudflare/cloudflared","command":["tunnel","run"]}' + +# On an existing project: PUT /api/projects/:id +curl -fsS -X PUT http://127.0.0.1:3000/api/projects/1 \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"command":["tunnel","run"],"entrypoint":["cloudflared"]}' + +# Clear an override, back to the image default +curl -fsS -X PUT http://127.0.0.1:3000/api/projects/1 \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"command":null}' +``` + +A project read returns `command` and `entrypoint` as arrays (or `null` when unset). + +MCP equivalents: `command` and `entrypoint` are fields on `moor_project_create`, `moor_project_update`, and `moor_deploy`. + ## Scheduled dangling-image cleanup Builds create new image layers and leave the previous tagged image as a dangling artifact. On an active host these add up fast (8 GB+ regenerates within minutes when several projects rebuild). The MCP tools `moor_cleanup_plan` + `moor_cleanup_execute` let you reclaim that space manually. diff --git a/lefthook.yml b/lefthook.yml index 40a0258..8da9091 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -9,3 +9,12 @@ pre-commit: typecheck-web: glob: "apps/web/**/*.{ts,tsx}" run: cd apps/web && bunx tsc -b + typecheck-contract: + glob: "packages/contract/**/*.ts" + run: bunx tsc --noEmit -p packages/contract/tsconfig.json + typecheck-cli: + glob: "packages/cli/**/*.ts" + run: bunx tsc --noEmit -p packages/cli/tsconfig.json + typecheck-mcp: + glob: "packages/mcp/**/*.ts" + run: bunx tsc --noEmit -p packages/mcp/tsconfig.json diff --git a/package.json b/package.json index 1e613a7..3e92319 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "start": "bun run apps/api/index.ts", "test": "bun test", "lint": "bunx biome check .", + "typecheck": "bunx tsc --noEmit -p apps/api/tsconfig.json && (cd apps/web && bunx tsc -b) && bunx tsc --noEmit -p packages/contract/tsconfig.json && bunx tsc --noEmit -p packages/cli/tsconfig.json && bunx tsc --noEmit -p packages/mcp/tsconfig.json", + "check": "bun run lint && bun run typecheck && bun run test", "lint:fix": "bunx biome check --write .", "format": "bunx biome format --write .", "prepare": "bunx lefthook install" @@ -29,6 +31,7 @@ "semantic-release": "^25.0.3", "semantic-release-ai-notes": "^0.2.3", "semantic-release-monorepo": "^8.0.2", + "semantic-release-plugin-decorators": "^4.0.0", "zod": "^4.3.6" } } diff --git a/packages/cli/.releaserc.json b/packages/cli/.releaserc.json index 5c06fe6..ec3434d 100644 --- a/packages/cli/.releaserc.json +++ b/packages/cli/.releaserc.json @@ -1,5 +1,5 @@ { - "extends": "semantic-release-monorepo", + "extends": "../../scripts/release/monorepo-shared.js", "branches": ["main"], "tagFormat": "cli-v${version}", "plugins": [ diff --git a/packages/cli/bin/moor.js b/packages/cli/bin/moor.js new file mode 100755 index 0000000..12fc33b --- /dev/null +++ b/packages/cli/bin/moor.js @@ -0,0 +1,2 @@ +#!/usr/bin/env bun +import "../dist/index.js"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 99ab8e6..347fec7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,17 +10,26 @@ }, "homepage": "https://github.com/caiopizzol/moor/tree/main/packages/cli", "bugs": "https://github.com/caiopizzol/moor/issues", - "keywords": ["moor", "cli", "docker"], + "keywords": [ + "moor", + "cli", + "docker" + ], "type": "module", "bin": { - "moor": "src/index.ts" + "moor": "bin/moor.js" }, - "files": ["src/"], + "files": [ + "bin/", + "dist/" + ], "publishConfig": { "access": "public" }, "scripts": { "dev": "bun run src/index.ts", - "build": "bun build src/index.ts --compile --outfile moor" + "build": "bun build src/index.ts --compile --outfile moor", + "build:package": "bun build src/index.ts --target bun --outfile dist/index.js", + "prepack": "bun run build:package" } } diff --git a/packages/cli/src/client.test.ts b/packages/cli/src/client.test.ts new file mode 100644 index 0000000..ee730ab --- /dev/null +++ b/packages/cli/src/client.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiGet, apiPost, apiPut, readErrorMessage, resolveProject } from "./client"; + +const originalFetch = globalThis.fetch; +const originalMoorUrl = process.env.MOOR_URL; +const originalMoorApiKey = process.env.MOOR_API_KEY; + +type FetchCall = { + input: Parameters[0]; + init: Parameters[1]; +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + restoreEnv("MOOR_URL", originalMoorUrl); + restoreEnv("MOOR_API_KEY", originalMoorApiKey); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +function configureClientEnv(): void { + process.env.MOOR_URL = "https://moor.test/"; + process.env.MOOR_API_KEY = "test-key"; +} + +function captureFetch(response: Response): FetchCall[] { + const calls: FetchCall[] = []; + globalThis.fetch = (async (...args: Parameters): Promise => { + const [input, init] = args; + calls.push({ input, init }); + return response; + }) as typeof fetch; + return calls; +} + +function firstCall(calls: FetchCall[]): FetchCall { + const call = calls[0]; + if (!call) throw new Error("expected fetch to be called"); + return call; +} + +describe("client API helpers", () => { + test("apiGet returns the raw response while using the shared contract client", async () => { + configureClientEnv(); + const rawResponse = new Response("not-json", { status: 418 }); + const calls = captureFetch(rawResponse); + + const res = await apiGet("/api/projects"); + + expect(res).toBe(rawResponse); + const call = firstCall(calls); + expect(call.input).toBe("https://moor.test/api/projects"); + expect(call.init?.method).toBe("GET"); + const headers = new Headers(call.init?.headers); + expect(headers.get("Authorization")).toBe("Bearer test-key"); + expect(headers.get("Accept")).toBe("application/json"); + }); + + test("apiPost and apiPut preserve raw responses and JSON request bodies", async () => { + configureClientEnv(); + const calls = captureFetch(new Response("{}", { status: 200 })); + + await apiPost("/api/projects/1/run", { no_cache: true }); + await apiPut("/api/projects/1/envs", [{ key: "A", value: "B" }]); + + const post = firstCall(calls); + expect(post.init?.method).toBe("POST"); + expect(post.init?.body).toBe(JSON.stringify({ no_cache: true })); + expect(new Headers(post.init?.headers).get("Content-Type")).toBe("application/json"); + + const put = calls[1]; + if (!put) throw new Error("expected second fetch call"); + expect(put.init?.method).toBe("PUT"); + expect(put.init?.body).toBe(JSON.stringify([{ key: "A", value: "B" }])); + expect(new Headers(put.init?.headers).get("Content-Type")).toBe("application/json"); + }); + + test("resolveProject uses the shared Project type without changing CLI errors", async () => { + configureClientEnv(); + captureFetch( + new Response( + JSON.stringify([ + { + id: 7, + name: "api", + status: "running", + github_url: null, + docker_image: "ghcr.io/example/api", + }, + ]), + { status: 200 }, + ), + ); + + const project = await resolveProject("7"); + + expect(project.name).toBe("api"); + expect(project.docker_image).toBe("ghcr.io/example/api"); + }); + + test("readErrorMessage keeps existing response parsing behavior", async () => { + await expect(readErrorMessage(new Response("", { status: 503 }))).resolves.toBe("HTTP 503"); + await expect( + readErrorMessage(new Response('{"error":"bad key"}', { status: 401 })), + ).resolves.toBe("bad key"); + }); +}); diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index ea7b7ad..0a2c2e7 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -1,3 +1,11 @@ +import { + createMoorApiClient, + type FetchLike, + type MoorApiClient, + type Project, + parseErrorMessage, +} from "../../contract/src/index"; + function getConfig(): { baseUrl: string; apiKey: string } { const baseUrl = process.env.MOOR_URL; const apiKey = process.env.MOOR_API_KEY; @@ -12,45 +20,40 @@ function getConfig(): { baseUrl: string; apiKey: string } { return { baseUrl: baseUrl.replace(/\/$/, ""), apiKey }; } -function headers(apiKey: string, json = false): Record { - const h: Record = { Authorization: `Bearer ${apiKey}` }; - if (json) h["Content-Type"] = "application/json"; - return h; +async function rawResponseRequest( + callClient: (client: MoorApiClient) => Promise, +): Promise { + let rawResponse: Response | undefined; + const fetchRawResponse: FetchLike = async (input, init) => { + rawResponse = await globalThis.fetch(input, init); + return new Response(null, { status: 204 }); + }; + const client = createMoorApiClient({ + ...getConfig(), + fetch: fetchRawResponse, + }); + + await callClient(client); + if (!rawResponse) throw new Error("No response received"); + return rawResponse; } export async function apiGet(path: string): Promise { - const { baseUrl, apiKey } = getConfig(); - return fetch(`${baseUrl}${path}`, { headers: headers(apiKey) }); + return rawResponseRequest((client) => client.get(path)); } export async function apiPost(path: string, body?: unknown): Promise { - const { baseUrl, apiKey } = getConfig(); - return fetch(`${baseUrl}${path}`, { - method: "POST", - headers: headers(apiKey, body !== undefined), - body: body !== undefined ? JSON.stringify(body) : undefined, - }); + return rawResponseRequest((client) => client.post(path, body)); } export async function apiPut(path: string, body: unknown): Promise { - const { baseUrl, apiKey } = getConfig(); - return fetch(`${baseUrl}${path}`, { - method: "PUT", - headers: headers(apiKey, true), - body: JSON.stringify(body), - }); + return rawResponseRequest((client) => client.put(path, body)); } -type Project = { - id: number; - name: string; - status: string; - container_id: string | null; - image_tag: string | null; - domain: string | null; - docker_image: string | null; - github_url: string | null; -}; +export async function readErrorMessage(res: Response): Promise { + const text = await res.text(); + return parseErrorMessage(text, res.status); +} export async function resolveProject(nameOrId: string): Promise { const res = await apiGet("/api/projects"); @@ -94,7 +97,7 @@ export async function streamSSE( if (line.startsWith("event: ")) { currentEvent = line.slice(7).trim(); } else if (line.startsWith("data: ")) { - const data = JSON.parse(line.slice(6)); + const data = JSON.parse(line.slice(6)) as string; if (currentEvent === "log") handlers.onLog?.(data); else if (currentEvent === "error") handlers.onError?.(data); else if (currentEvent === "done") handlers.onDone?.(data); diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts index 91959bb..e2af01c 100644 --- a/packages/cli/src/commands/env.ts +++ b/packages/cli/src/commands/env.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPut, resolveProject } from "../client"; +import { apiGet, apiPost, apiPut, readErrorMessage, resolveProject } from "../client"; type EnvVar = { key: string; value: string }; @@ -22,7 +22,7 @@ async function envList(args: string[]) { const project = await resolveProject(projectName); const res = await apiGet(`/api/projects/${project.id}/envs`); if (!res.ok) { - console.error(`Failed: ${await res.text()}`); + console.error(`Failed: ${await readErrorMessage(res)}`); process.exit(1); } @@ -61,7 +61,7 @@ async function envSet(args: string[]) { // Fetch existing env vars and merge const existingRes = await apiGet(`/api/projects/${project.id}/envs`); if (!existingRes.ok) { - console.error(`Failed to get env vars: ${await existingRes.text()}`); + console.error(`Failed to get env vars: ${await readErrorMessage(existingRes)}`); process.exit(1); } const existing = (await existingRes.json()) as EnvVar[]; @@ -75,7 +75,7 @@ async function envSet(args: string[]) { const setRes = await apiPut(`/api/projects/${project.id}/envs`, allVars); if (!setRes.ok) { - console.error(`Failed to set env vars: ${await setRes.text()}`); + console.error(`Failed to set env vars: ${await readErrorMessage(setRes)}`); process.exit(1); } @@ -89,7 +89,7 @@ async function envSet(args: string[]) { await apiPost(`/api/projects/${project.id}/stop`); const startRes = await apiPost(`/api/projects/${project.id}/start`); if (!startRes.ok) { - console.error(`Warning: failed to restart: ${await startRes.text()}`); + console.error(`Warning: failed to restart: ${await readErrorMessage(startRes)}`); process.exit(1); } console.log(`${project.name} restarted.`); diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index 42dbdf7..b27e760 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -1,4 +1,4 @@ -import { apiPost, resolveProject } from "../client"; +import { apiPost, readErrorMessage, resolveProject } from "../client"; export async function execCommand(args: string[]) { const projectName = args[0]; @@ -13,7 +13,7 @@ export async function execCommand(args: string[]) { const res = await apiPost(`/api/projects/${project.id}/exec`, { command }); if (!res.ok) { - console.error(`Failed: ${await res.text()}`); + console.error(`Failed: ${await readErrorMessage(res)}`); process.exit(1); } diff --git a/packages/cli/src/commands/history.ts b/packages/cli/src/commands/history.ts index 745694c..0f8dea6 100644 --- a/packages/cli/src/commands/history.ts +++ b/packages/cli/src/commands/history.ts @@ -1,4 +1,4 @@ -import { apiGet, resolveProject } from "../client"; +import { apiGet, readErrorMessage, resolveProject } from "../client"; type HistoryResponse = { from_ms: number; @@ -77,7 +77,7 @@ export async function historyCommand(args: string[]) { const from = to - parsed.hours * 3_600_000; const res = await apiGet(`/api/projects/${project.id}/stats/history?from=${from}&to=${to}`); if (!res.ok) { - console.error(`Failed to get history: ${res.status} ${await res.text()}`); + console.error(`Failed to get history: ${res.status} ${await readErrorMessage(res)}`); process.exit(1); } diff --git a/packages/cli/src/commands/rebuild.ts b/packages/cli/src/commands/rebuild.ts index 1aa8957..c4f2ddd 100644 --- a/packages/cli/src/commands/rebuild.ts +++ b/packages/cli/src/commands/rebuild.ts @@ -1,4 +1,4 @@ -import { apiPost, resolveProject, streamSSE } from "../client"; +import { apiPost, readErrorMessage, resolveProject, streamSSE } from "../client"; export async function rebuildCommand(args: string[]) { let noCache = false; @@ -21,7 +21,7 @@ export async function rebuildCommand(args: string[]) { const res = await apiPost(`/api/projects/${project.id}/run${query}`); if (!res.ok && res.headers.get("content-type")?.includes("text/event-stream") === false) { - const body = await res.text(); + const body = await readErrorMessage(res); console.error(`Failed: ${body}`); process.exit(1); } diff --git a/packages/cli/src/commands/restart.ts b/packages/cli/src/commands/restart.ts index 7e80bd9..7b313ef 100644 --- a/packages/cli/src/commands/restart.ts +++ b/packages/cli/src/commands/restart.ts @@ -1,4 +1,4 @@ -import { apiPost, resolveProject } from "../client"; +import { apiPost, readErrorMessage, resolveProject } from "../client"; export async function restartCommand(args: string[]) { const projectName = args[0]; @@ -12,14 +12,14 @@ export async function restartCommand(args: string[]) { console.log(`Stopping ${project.name}...`); const stopRes = await apiPost(`/api/projects/${project.id}/stop`); if (!stopRes.ok) { - console.error(`Failed to stop: ${await stopRes.text()}`); + console.error(`Failed to stop: ${await readErrorMessage(stopRes)}`); process.exit(1); } console.log(`Starting ${project.name}...`); const startRes = await apiPost(`/api/projects/${project.id}/start`); if (!startRes.ok) { - console.error(`Failed to start: ${await startRes.text()}`); + console.error(`Failed to start: ${await readErrorMessage(startRes)}`); process.exit(1); } diff --git a/packages/contract/package.json b/packages/contract/package.json new file mode 100644 index 0000000..fe23c85 --- /dev/null +++ b/packages/contract/package.json @@ -0,0 +1,30 @@ +{ + "name": "@moor-sh/contract", + "version": "0.0.0-development", + "private": true, + "description": "Shared HTTP API contract for moor clients. Internal workspace package, never published: consumers bundle its source at publish time (see the prepack scripts in packages/cli and packages/mcp).", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/caiopizzol/moor.git", + "directory": "packages/contract" + }, + "homepage": "https://github.com/caiopizzol/moor/tree/main/packages/contract", + "bugs": "https://github.com/caiopizzol/moor/issues", + "keywords": [ + "moor", + "contract", + "api", + "types" + ], + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "src/" + ], + "scripts": { + "typecheck": "bunx tsc --noEmit -p tsconfig.json", + "test": "bun test src" + } +} diff --git a/packages/contract/src/client.test.ts b/packages/contract/src/client.test.ts new file mode 100644 index 0000000..bdb62c2 --- /dev/null +++ b/packages/contract/src/client.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { createMoorApiClient, type FetchLike, type MoorApiError } from "./client"; + +describe("createMoorApiClient", () => { + test("sends bearer auth and JSON headers", async () => { + const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = []; + const mockFetch: FetchLike = async (input, init) => { + calls.push({ input, init }); + return Response.json({ ok: true }); + }; + const client = createMoorApiClient({ + baseUrl: "https://moor.example/", + apiKey: "secret", + fetch: mockFetch, + }); + + const result = await client.post<{ ok: true }>("/api/projects", { name: "app" }); + + expect(result).toEqual({ ok: true }); + expect(calls[0].input).toBe("https://moor.example/api/projects"); + expect(calls[0].init?.method).toBe("POST"); + expect(calls[0].init?.body).toBe(JSON.stringify({ name: "app" })); + const headers = new Headers(calls[0].init?.headers); + expect(headers.get("Authorization")).toBe("Bearer secret"); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("Accept")).toBe("application/json"); + }); + + test("returns undefined for 204 responses", async () => { + const client = createMoorApiClient({ + baseUrl: "https://moor.example", + apiKey: "secret", + fetch: async () => new Response(null, { status: 204 }), + }); + + await expect(client.delete("/api/projects/1")).resolves.toBeUndefined(); + }); + + test("turns JSON error bodies into MoorApiError", async () => { + const client = createMoorApiClient({ + baseUrl: "https://moor.example", + apiKey: "secret", + fetch: async () => Response.json({ error: "bad api key" }, { status: 401 }), + }); + + await expect(client.get("/api/projects")).rejects.toMatchObject({ + name: "MoorApiError", + status: 401, + message: "bad api key", + body: JSON.stringify({ error: "bad api key" }), + } satisfies Partial); + }); + + test("falls back to text error bodies", async () => { + const client = createMoorApiClient({ + baseUrl: "https://moor.example", + apiKey: "secret", + fetch: async () => new Response("Project not found", { status: 404 }), + }); + + await expect(client.get("/api/projects/99")).rejects.toMatchObject({ + status: 404, + message: "Project not found", + } satisfies Partial); + }); +}); diff --git a/packages/contract/src/client.ts b/packages/contract/src/client.ts new file mode 100644 index 0000000..0497c0d --- /dev/null +++ b/packages/contract/src/client.ts @@ -0,0 +1,96 @@ +export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; + +export type MoorApiClientOptions = { + baseUrl: string; + apiKey: string; + fetch?: FetchLike; +}; + +export type MoorApiClient = { + get: (path: string, init?: RequestInit) => Promise; + post: (path: string, body?: unknown, init?: RequestInit) => Promise; + put: (path: string, body: unknown, init?: RequestInit) => Promise; + delete: (path: string, init?: RequestInit) => Promise; +}; + +export class MoorApiError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, message: string, body = "") { + super(message); + this.name = "MoorApiError"; + this.status = status; + this.body = body; + } +} + +export function createMoorApiClient(options: MoorApiClientOptions): MoorApiClient { + const baseUrl = normalizeBaseUrl(options.baseUrl); + const apiKey = options.apiKey; + const fetchImpl = options.fetch ?? globalThis.fetch; + + async function request(method: string, path: string, body?: unknown, init?: RequestInit) { + const hasBody = body !== undefined; + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${apiKey}`); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + if (hasBody && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + + const res = await fetchImpl(joinUrl(baseUrl, path), { + ...init, + method, + headers, + body: hasBody ? JSON.stringify(body) : init?.body, + }); + + if (!res.ok) { + const text = await res.text(); + throw new MoorApiError(res.status, parseErrorMessage(text, res.status), text); + } + + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + return { + get: (path: string, init?: RequestInit) => + request("GET", path, undefined, init), + post: (path: string, body?: unknown, init?: RequestInit) => + request("POST", path, body, init), + put: (path: string, body: unknown, init?: RequestInit) => + request("PUT", path, body, init), + delete: (path: string, init?: RequestInit) => + request("DELETE", path, undefined, init), + }; +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function joinUrl(baseUrl: string, path: string): string { + if (path.startsWith("/")) return `${baseUrl}${path}`; + return `${baseUrl}/${path}`; +} + +export function parseErrorMessage(body: string, status: number): string { + if (body) { + try { + const parsed = JSON.parse(body) as unknown; + if (isJsonObject(parsed) && "error" in parsed) { + const error = parsed.error; + if (typeof error === "string") return error; + return JSON.stringify(error); + } + } catch { + return body; + } + return body; + } + return `HTTP ${status}`; +} + +export function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts new file mode 100644 index 0000000..90fdace --- /dev/null +++ b/packages/contract/src/index.ts @@ -0,0 +1,3 @@ +export * from "./client"; +export * from "./types"; +export * from "./validators"; diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts new file mode 100644 index 0000000..5482efd --- /dev/null +++ b/packages/contract/src/types.ts @@ -0,0 +1,525 @@ +export type IsoDateString = string; + +export type LiveStatus = "running" | "stopped" | "error" | "missing"; + +export type Project = { + id: number; + name: string; + github_url: string | null; + docker_image: string | null; + branch: string; + dockerfile: string; + image_tag: string | null; + container_id: string | null; + status: string; + domain: string | null; + domain_port: number | null; + restart_policy: string; + memory_limit_mb: number | null; + cpus: number | null; + source_credential_id: number | null; + command: string[] | null; + entrypoint: string[] | null; + live_status: LiveStatus | null; + live_exit_code: number | null; + live_checked_at: IsoDateString | null; + live_error: string | null; + created_at: IsoDateString; +}; + +export type CreateProjectRequest = { + name: string; + github_url?: string | null; + docker_image?: string | null; + branch?: string; + dockerfile?: string; + domain?: string | null; + domain_port?: number | null; + restart_policy?: string; + memory_limit_mb?: number | null; + cpus?: number | null; + source_credential_id?: number | null; + command?: string[] | null; + entrypoint?: string[] | null; +}; + +export type UpdateProjectRequest = Partial; + +export type DeleteProjectResponse = + | { ok: true; project_deleted: true; volumes_purged: number } + | { + ok: false; + project_deleted: true; + caddy_failed: boolean; + volumes_purged: number; + volumes_failed: Array<{ name: string; error: string }>; + message: string; + }; + +export type Cron = { + id: number; + project_id: number; + name: string; + schedule: string; + command: string; + enabled: number; + created_at: IsoDateString; +}; + +export type CreateCronRequest = { + name: string; + schedule: string; + command: string; +}; + +export type UpdateCronRequest = Partial & { + enabled?: number | boolean; +}; + +export type Run = { + id: number; + cron_id: number | null; + project_id: number; + started_at: IsoDateString; + finished_at: IsoDateString | null; + exit_code: number | null; + stdout: string | null; + stderr: string | null; + duration_ms: number | null; + started_at_ms: number | null; + finished_at_ms: number | null; + stdout_total_bytes: number | null; + stderr_total_bytes: number | null; + cron_name?: string | null; + cron_command?: string | null; +}; + +export type CompactRun = Omit & { + stdout_bytes: number; + stderr_bytes: number; + stdout_total_bytes: number; + stderr_total_bytes: number; + cron_name: string | null; + cron_command: string | null; +}; + +export type ListRunsResponse = { + runs: T[]; + total: number; +}; + +export type BuildOutputResponse = Run | { output: null }; + +export type StopRunResult = + | "cancelled_cron" + | "cancelled" + | "not_cancellable" + | "already_finished" + | "not_active" + | "not_found"; + +export type StopRunResponse = + | { ok: true; result: Extract } + | { ok: false; result: Exclude }; + +export type EnvVar = { + id: number; + project_id: number; + key: string; + value: string; +}; + +export type SetEnvVarsRequest = Array<{ key: string; value: string }>; + +export type PortMapping = { + id: number; + project_id: number; + host_port: number; + container_port: number; + protocol: string; +}; + +export type Volume = { + id: number; + project_id: number; + name: string; + target: string; + docker_name: string; +}; + +export type CreateVolumeRequest = { + name: string; + target: string; +}; + +export type DeleteVolumeResponse = { + ok: true; + docker_name: string; + message: string; +}; + +export type ProjectFile = + | { + id: number; + project_id: number; + path: string; + mode: string; + source: "inline"; + env_ref: null; + } + | { + id: number; + project_id: number; + path: string; + mode: string; + source: "env"; + env_ref: string; + }; + +export type CreateProjectFileRequest = + | { path: string; content: string; env_ref?: never; mode?: string } + | { path: string; env_ref: string; content?: never; mode?: string }; + +export type DeleteProjectFileResponse = { ok: true }; + +export type RedactedSecretKind = "github_classic_pat" | "github_fine_grained_pat" | "unknown"; + +export type RedactedSecret = { + configured: true; + kind: RedactedSecretKind; +}; + +export type RegistryCredential = { + id: number; + hostname: string; + username: string; + secret: RedactedSecret; + created_at: IsoDateString; + updated_at: IsoDateString; +}; + +export type ListRegistryCredentialsResponse = { + rows: RegistryCredential[]; +}; + +export type CreateRegistryCredentialRequest = { + hostname: string; + username: string; + secret: string; +}; + +export type UpdateRegistryCredentialRequest = { + username?: string; + secret?: string; +}; + +export type SourceCredentialState = "active" | "failed"; + +export type SourceCredential = { + id: number; + hostname: string; + label: string; + username: string; + secret: RedactedSecret; + state: SourceCredentialState; + expires_at: IsoDateString | null; + last_checked_at: IsoDateString | null; + last_check_status: string | null; + created_at: IsoDateString; + updated_at: IsoDateString; +}; + +export type ListSourceCredentialsResponse = { + rows: SourceCredential[]; +}; + +export type CreateSourceCredentialRequest = { + hostname: string; + label: string; + username: string; + secret: string; + expires_at?: IsoDateString | null; +}; + +export type UpdateSourceCredentialRequest = { + label?: string; + username?: string; + secret?: string; + expires_at?: IsoDateString | null; +}; + +export type DeleteSourceCredentialConflict = { + error: "credential_in_use"; + message: string; + projects: string[]; +}; + +export type SourceCredentialCheckRequest = { + github_url: string; + branch?: string; + source_credential_id?: number; +}; + +export type SourceCredentialCheckSuccess = { + ok: true; + reachable: true; + default_branch?: string; + head_sha?: string; + ref_sha?: string; + auto_selected_credential_id?: number; +}; + +export type SourceCredentialCheckFailure = + | { ok: false; code: "invalid_url"; reason: string } + | { ok: false; code: "credential_not_found"; source_credential_id: number } + | { + ok: false; + code: "credential_host_mismatch"; + source_credential_id: number; + credential_hostname: string; + request_hostname: string; + } + | { ok: false; code: "source_credential_required"; hostname: string } + | { + ok: false; + code: "source_credential_ambiguous"; + hostname: string; + candidates: Array<{ id: number; label: string }>; + } + | { ok: false; code: "credential_not_active"; source_credential_id: number; state: string } + | { ok: false; code: "clone_auth_failed"; source_credential_id?: number } + | { ok: false; code: "repo_not_found_or_not_scoped"; source_credential_id?: number } + | { ok: false; code: "branch_not_found"; branch: string } + | { ok: false; code: "network_unreachable" } + | { ok: false; code: "source_access_denied_or_not_found" } + | { ok: false; code: "git_error" }; + +export type SourceCredentialCheckResult = + | SourceCredentialCheckSuccess + | SourceCredentialCheckFailure; + +export type ContainerStats = + | { + running: true; + cpu_percent: number; + memory_bytes: number; + memory_limit_bytes: number; + memory_percent: number; + network_rx_bytes: number; + network_tx_bytes: number; + block_read_bytes: number; + block_write_bytes: number; + pids: number; + } + | { + running: false; + cpu_percent: 0; + memory_bytes: 0; + memory_limit_bytes: 0; + memory_percent: 0; + network_rx_bytes: 0; + network_tx_bytes: 0; + block_read_bytes: 0; + block_write_bytes: 0; + pids: 0; + }; + +export type ProjectHistorySample = { + sampled_at_ms: number; + status: string; + cpu_percent: number | null; + mem_bytes: number | null; + mem_percent: number | null; + net_rx_rate: number | null; + net_tx_rate: number | null; + blk_read_rate: number | null; + blk_write_rate: number | null; + pids: number | null; +}; + +export type ProjectHistoryEvent = { + occurred_at_ms: number; + source: string; + action: string; + container_id: string | null; + time_nano: number | null; +}; + +export type ProjectHistorySummary = { + sample_count: number; + running_sample_count: number; + cpu_percent_avg: number | null; + cpu_percent_max: number | null; + mem_bytes_max: number | null; + net_rx_bytes_total: number; + net_tx_bytes_total: number; + event_counts: Record; + has_gap: boolean; +}; + +export type ProjectHistory = { + from_ms: number; + to_ms: number; + samples: ProjectHistorySample[]; + events: ProjectHistoryEvent[]; + summary: ProjectHistorySummary; +}; + +export type ServerStats = { + hostname: string; + os: string; + uptime: string; + cpu: { percent: number; cores: number }; + load: { one_min: number; cores: number; normalized_percent: number }; + memory: { total: string; used: string; percent: number }; + disk: { total: string; used: string; percent: number }; + disks: Array<{ mount: string; total: string; used: string; percent: number; label?: string }>; + containers: { running: number; total: number }; + docker: DockerDisk | null; +}; + +export type DockerDiskCategory = { + bytes: number; + reclaimable_bytes: number; + count: number; + unused_count: number; +}; + +export type DockerDisk = { + images: DockerDiskCategory; + containers: DockerDiskCategory & { stopped_count: number }; + volumes: DockerDiskCategory; + build_cache: { bytes: number; reclaimable_bytes: number; count: number }; +}; + +export type ActiveWorkCounts = { + builds_in_flight: number; + execs_in_flight: number; + crons_in_flight: number; + terminals_open: number; +}; + +export type DrainState = { + enabled: boolean; + reason: string | null; + started_at: IsoDateString | null; + expires_at: IsoDateString | null; + clear_after_version: string | null; +}; + +export type DrainStatusResponse = { + state: DrainState; + active_work: ActiveWorkCounts; +}; + +export type EnableDrainRequest = { + reason?: string; + ttl_minutes?: number; + clear_after_version?: string; +}; + +export type DrainMutationResponse = { + state: DrainState; +}; + +export type DrainRefusal = { + error: "moor is draining"; + reason: string | null; + expires_at: IsoDateString | null; + hint: string; +}; + +export type UpdateStatus = { + current: { + version: string; + image_id: string | null; + repo_digest: string | null; + started_at: IsoDateString; + }; + available: { + latest_tag: string; + latest_digest: string | null; + update_available: boolean | null; + registry_error: string | null; + }; + active_work: ActiveWorkCounts; + db_backup: { + last_backup_at: IsoDateString | null; + age_seconds: number | null; + location: string | null; + }; + safe_to_update: boolean; + unsafe_reasons: string[]; + recommended_command: string; +}; + +export type UpdateAuditState = + | "in_progress" + | "success" + | "rolled_back" + | "rollback_failed" + | "failed" + | "crashed"; + +export type UpdateAudit = { + id: number; + started_at: IsoDateString; + started_at_ms: number; + finished_at: IsoDateString | null; + finished_at_ms: number | null; + duration_ms: number | null; + state: UpdateAuditState; + from_digest: string | null; + to_digest: string | null; + prev_image_id: string | null; + backup_path: string | null; + rollback_error: string | null; + error_log: string | null; +}; + +export type ListUpdateAuditResponse = { + rows: UpdateAudit[]; +}; + +export type UpdateApplyRequest = { + target_digest?: string; + bypass?: Array<"active_work" | "unknown_digest">; +}; + +export type UpdateApplyError = + | { code: "preflight_failed"; reason: string; unsafe_reasons?: string[] } + | { code: "context_failed"; reason: string } + | { code: "current_image_unknown"; reason: string } + | { code: "already_in_progress" } + | { code: "race_active_work"; counts: Record } + | { code: "backup_failed"; reason: string } + | { code: "respawner_launch_failed"; reason: string }; + +export type UpdateApplyResponse = { audit_id: number }; + +export type UpdateApplyErrorResponse = { + error: UpdateApplyError; +}; + +export type LogsResponse = { + logs: string; + state?: "ok" | "exited" | "no_container" | "missing"; + lastTimestamp?: number; +}; + +export type ExecResponse = { + exitCode: number; + stdout: string; + stderr: string; +}; + +export type TerminalSession = { + execId: string; + projectId: number; + startedAt: IsoDateString; + lastCommand: string; +}; + +export type ListTerminalSessionsResponse = { + sessions: TerminalSession[]; +}; diff --git a/packages/contract/src/validators.test.ts b/packages/contract/src/validators.test.ts new file mode 100644 index 0000000..779ebd1 --- /dev/null +++ b/packages/contract/src/validators.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { validateCronSchedule, validateGithubRepoUrl, validateGithubUrl } from "./validators"; + +describe("GitHub URL validators", () => { + test("validateGithubUrl accepts exactly github.com and www.github.com over https", () => { + expect(() => validateGithubUrl("https://github.com/moor-sh/moor")).not.toThrow(); + expect(() => validateGithubUrl("https://www.github.com/moor-sh/moor")).not.toThrow(); + }); + + test("validateGithubUrl rejects invalid, lookalike, subdomain, and non-https URLs", () => { + expect(() => validateGithubUrl("not a url")).toThrow("not a valid URL"); + expect(() => validateGithubUrl("https://evilgithub.com/owner/repo")).toThrow( + 'got "evilgithub.com"', + ); + expect(() => validateGithubUrl("https://gist.github.com/user/id")).toThrow( + "github.com or www.github.com", + ); + expect(() => validateGithubUrl("http://github.com/owner/repo")).toThrow("must use https"); + }); + + test("validateGithubRepoUrl accepts canonical owner/repo URLs", () => { + expect(() => validateGithubRepoUrl("https://github.com/owner/repo")).not.toThrow(); + expect(() => validateGithubRepoUrl("https://www.github.com/owner/repo.git/")).not.toThrow(); + }); + + test("validateGithubRepoUrl rejects non-repo GitHub URLs and URL modifiers", () => { + expect(() => validateGithubRepoUrl("https://gist.github.com/owner/repo")).toThrow( + "github.com or www.github.com", + ); + expect(() => validateGithubRepoUrl("https://github.com/owner/repo/tree/main")).toThrow( + "/owner/repo", + ); + expect(() => validateGithubRepoUrl("http://github.com/owner/repo")).toThrow("must use https"); + expect(() => validateGithubRepoUrl("ssh://github.com/owner/repo")).toThrow("must use https"); + expect(() => validateGithubRepoUrl("https://github.com/owner/repo?tab=readme")).toThrow( + "query parameters", + ); + expect(() => validateGithubRepoUrl("https://github.com/owner/repo#readme")).toThrow( + "URL fragment", + ); + }); +}); + +describe("validateCronSchedule", () => { + test("accepts numeric 5-field schedules supported by the scheduler", () => { + expect(validateCronSchedule("*/5 0-23/2 * 1,12 0")).toBeNull(); + expect(validateCronSchedule("0 3 * * *")).toBeNull(); + }); + + test("rejects unsupported field counts and crontab syntax", () => { + expect(validateCronSchedule("* * *")).toBe( + "schedule must have exactly 5 space-separated fields (got 3)", + ); + expect(validateCronSchedule("0 0 * jan *")).toBe( + "month: month/day names are not supported, use numeric values", + ); + expect(validateCronSchedule("0 0 L * *")).toBe("day-of-month: ?, L, W, # are not supported"); + }); + + test("rejects values and ranges the scheduler cannot match correctly", () => { + expect(validateCronSchedule("0 0 * * 7")).toBe("day-of-week: 7 out of bounds [0-6]"); + expect(validateCronSchedule("1,,2 * * * *")).toBe("minute: empty list element"); + expect(validateCronSchedule("10-1 * * * *")).toBe("minute: range 10-1 is descending"); + expect(validateCronSchedule("*/0 * * * *")).toBe( + 'minute: step must be a positive integer (got "0")', + ); + }); +}); diff --git a/packages/contract/src/validators.ts b/packages/contract/src/validators.ts new file mode 100644 index 0000000..8a5f1ad --- /dev/null +++ b/packages/contract/src/validators.ts @@ -0,0 +1,111 @@ +// Must stay in lockstep with the API's check (apps/api/github-url.ts): https only, +// hostname exactly github.com or www.github.com. Anything looser passes client +// validation but is rejected by the API at build time. +export function validateGithubUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`github_url is not a valid URL: ${url}`); + } + if (parsed.protocol !== "https:") { + throw new Error(`github_url must use https (got protocol "${parsed.protocol}")`); + } + const host = parsed.hostname; + if (host !== "github.com" && host !== "www.github.com") { + throw new Error(`github_url must use github.com or www.github.com (got "${host}")`); + } +} + +export function validateGithubRepoUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`github_url is not a valid URL: ${url}`); + } + if (parsed.protocol !== "https:") { + throw new Error(`github_url must use https (got protocol "${parsed.protocol}")`); + } + if (parsed.search) { + throw new Error(`github_url must not contain query parameters (got "${parsed.search}")`); + } + if (parsed.hash) { + throw new Error(`github_url must not contain a URL fragment (got "${parsed.hash}")`); + } + const host = parsed.hostname; + if (host !== "github.com" && host !== "www.github.com") { + throw new Error(`github_url must use github.com or www.github.com (got "${host}")`); + } + if (!/^\/[^/]+\/[^/]+?(\.git)?\/?$/.test(parsed.pathname)) { + throw new Error( + `github_url must point to /owner/repo (with optional .git); got "${parsed.pathname}"`, + ); + } +} + +const CRON_FIELDS: ReadonlyArray<{ name: string; min: number; max: number }> = [ + { name: "minute", min: 0, max: 59 }, + { name: "hour", min: 0, max: 23 }, + { name: "day-of-month", min: 1, max: 31 }, + { name: "month", min: 1, max: 12 }, + { name: "day-of-week", min: 0, max: 6 }, +]; + +const CRON_PART_PATTERNS = [ + /^\*$/, + /^(\d+)$/, + /^(\d+)-(\d+)$/, + /^\*\/(\d+)$/, + /^(\d+)-(\d+)\/(\d+)$/, +]; + +export function validateCronSchedule(schedule: string): string | null { + const parts = schedule.trim().split(/\s+/); + if (parts.length !== 5) { + return `schedule must have exactly 5 space-separated fields (got ${parts.length})`; + } + for (let i = 0; i < 5; i++) { + const field = CRON_FIELDS[i]; + const err = validateCronField(parts[i], field.min, field.max, field.name); + if (err) return err; + } + return null; +} + +function validateCronField(field: string, min: number, max: number, name: string): string | null { + if (field === "*") return null; + if (/[?LW#]/i.test(field)) return `${name}: ?, L, W, # are not supported`; + if (/[a-zA-Z]/.test(field)) { + return `${name}: month/day names are not supported, use numeric values`; + } + + for (const part of field.split(",")) { + if (part === "") return `${name}: empty list element`; + + const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null); + if (!match) return `${name}: invalid expression "${part}"`; + + const groups = match.slice(1); + if (groups.length === 1 && match[0].startsWith("*/")) { + const step = Number(groups[0]); + if (step <= 0) return `${name}: step must be a positive integer (got "${groups[0]}")`; + } else if (groups.length === 1) { + const n = Number(groups[0]); + if (n < min || n > max) return `${name}: ${n} out of bounds [${min}-${max}]`; + } else if (groups.length === 2) { + const a = Number(groups[0]); + const b = Number(groups[1]); + if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`; + if (a > b) return `${name}: range ${a}-${b} is descending`; + } else if (groups.length === 3) { + const a = Number(groups[0]); + const b = Number(groups[1]); + const step = Number(groups[2]); + if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`; + if (a > b) return `${name}: range ${a}-${b} is descending`; + if (step <= 0) return `${name}: step must be a positive integer (got "${groups[2]}")`; + } + } + return null; +} diff --git a/packages/contract/tsconfig.json b/packages/contract/tsconfig.json new file mode 100644 index 0000000..7a9063b --- /dev/null +++ b/packages/contract/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/packages/mcp/.releaserc.json b/packages/mcp/.releaserc.json index dc5d6d6..b94f493 100644 --- a/packages/mcp/.releaserc.json +++ b/packages/mcp/.releaserc.json @@ -1,5 +1,5 @@ { - "extends": "semantic-release-monorepo", + "extends": "../../scripts/release/monorepo-shared.js", "branches": ["main"], "tagFormat": "mcp-v${version}", "plugins": [ diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5f19be9..1fccec9 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -75,16 +75,107 @@ Exit 0 with no output means the MCP connected, authenticated, and shut down clea ## Tools -The MCP server exposes: - -- `moor_status` - list all projects with status, source, and domain -- `moor_logs` - get recent container logs for a project (with tail length) -- `moor_rebuild` - rebuild a project from source -- `moor_restart` - stop and start a project's container -- `moor_exec` - run a command inside a project's container -- `moor_env_list` - list environment variables for a project -- `moor_env_set` - set environment variables and restart -- `moor_stats` - host CPU / memory / disk / container counts +This is the full reference. For the ~10 tools an agent actually needs for the core loop, see the [recommended agent workflow](https://github.com/caiopizzol/moor/blob/main/docs/agent-workflow.md). + +The table below is generated from the tool registrations. Do not edit it by hand; run `bun run docs` in this package to regenerate. + + +The server registers 53 tools. Regenerate this section with `bun run docs` after changing any tool. + +### Projects + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_status` | List Projects | List all projects managed by Moor. `status` is moor's recorded state (only changes on explicit start/stop/build/cancel). `live_status` is Docker's view at last successful inspect; differences (e.g. recorded='running' live='error') mean moor missed an external change like a host docker stop, crash, or OOM kill. `live_error` non-null means the most recent inspect failed and the live_* values are the last successful snapshot, not necessarily current. | +| `moor_project_get` | Get Project | Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy). | +| `moor_project_create` | Create Project | Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild to bring it up, or use moor_deploy to create and start in one step. | +| `moor_project_update` | Update Project | Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately. Resource-limit changes (memory_limit_mb, cpus) take effect on the next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run) — an already-running container keeps its existing limits. | +| `moor_project_delete` | Delete Project | Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible. Named Docker volumes are preserved by default (data survives so a recreated project can remount them); pass purge_volumes: true to also delete the underlying Docker volumes — that deletion is also irreversible. | +| `moor_deploy` | Deploy Project | Create-or-update a project end to end: metadata, env vars (merged into existing), and an optional build/run. Default fails if the project already exists; pass update_existing: true to upsert. When run: true (default), waits for the full Docker build/pull and start, which can take minutes for large images. Errors are tagged by the failing step ([create], [update], [set_env], or [run]) and do not roll back earlier steps. | + +### Deployments & runs + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_logs` | Get Container Logs | Get recent logs from a project's container. Annotates output with state: ok (container running), exited (container is stopped but Docker still has logs), no_container (project never started), or missing (container_id is set but Docker doesn't have it). Throws only on docker_error (Docker daemon 5xx / unreachable) so an operator can distinguish infrastructure failure from app silence — pre-#74 the tool returned empty logs for all of these. | +| `moor_rebuild` | Rebuild Project | Rebuild a project from source (git pull + docker build) and restart the container. Returns the build output when it finishes. While a build is in flight, the most recent moor_runs entry has finished_at=null — call moor_run_get on its id to tail the live output. Use moor_rebuild for code, Dockerfile, or base-image changes. For env vars / resource limits / port / volume / restart-policy changes, or to recover a crashed container from the existing image, use moor_restart — it skips the build and is much faster. | +| `moor_restart` | Restart Project | Stop and recreate a project's container from its existing image. Does NOT pull from git or rebuild — uses the existing image_tag. Right tool for: applying changed env vars / resource limits / ports / volumes / restart policy, recovering a crashed container, or simply bouncing the process. Wrong tool for: code or Dockerfile changes (use moor_rebuild — those need a new image). | +| `moor_runs` | List Project Run History | Paginated list of cron runs and build runs for a project. Returns one compact line per run (id, type, status, exit code, duration, output byte counts, timestamps) — stdout/stderr bodies are NOT included to avoid blowing token budgets on large build outputs. Use moor_run_get(run_id) to fetch the stored output for a single run (cron rows store full output; build/manual rows store at most a 64 KiB tail with the original total bytes recorded separately). | +| `moor_run_get` | Get Run Detail | Fetch one cron or build run with its stdout and stderr. Output is tail-truncated (default 8 KiB per stream; max 65536) to keep responses under typical agent token limits. Use tail_bytes=0 for metadata-only. | +| `moor_run_stop` | Stop or Cancel a Run | Stops an active cron run or cancels an active build/pull run (from moor_rebuild / moor_deploy). Closing the connection to the Docker build/pull endpoint aborts the daemon-side job. Cancellation is only valid during the build/pull streaming phase — once the build finishes and container start has begun, the call returns not_cancellable. Returns one of: cancelled, cancelled_cron, not_cancellable, already_finished, not_active, not_found. These are all expected outcomes, not errors — the tool throws only on unexpected server failures. | + +### Exec & terminal + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_exec` | Execute Command | Run a shell command inside a project's running container. Bounded by a per-call timeout (default 10 min, max 1 h). For jobs that may exceed an hour, use moor_exec_async. | +| `moor_exec_async` | Start Async Exec | Run a long-lived command inside a project's container, returning immediately with a run_id. Use moor_exec_status to poll for output and exit code; moor_exec_stop to terminate. Bounded by an optional timeout_ms (default 86400000 = 24h; min 60000 = 1 min; max 86400000). The recorded output is tail-truncated to the last 64 KiB per stream; stdout_total_bytes and stderr_total_bytes report the full pre-truncation byte count. | +| `moor_exec_status` | Get Async Exec Status | Return the current state of an async exec run: state, exit code (when finished), running tail of stdout/stderr (default 8 KiB each inline; the API stores up to 64 KiB), total bytes seen, duration, and any error message. State is one of: running, exited, stopped, timed_out, error. Pass tail_bytes to control how many bytes of each stream are returned inline (0 to 65536; default 8192). The API's 64 KiB-per-stream storage cap is unchanged — tail_bytes only controls what the MCP tool returns to keep responses under typical agent token limits. | +| `moor_exec_stop` | Stop Async Exec | Terminate a running async exec by run_id. Walks the descendant process tree inside the container and sends SIGTERM then SIGKILL. Always transitions the run to a terminal state: state=stopped on clean termination (all descendants gone), state=error if any descendant survived OR if the kill handle was lost (moor restart, missing pidfile). Stop is NOT retry-safe — the kill script removes the pidfile after every attempt, and reparented survivors are unreachable from the original PID. | + +### Environment, cron, volumes & files + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_env_list` | List Environment Variables | List all environment variables set for a project. | +| `moor_env_set` | Set Environment Variables | Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running. | +| `moor_cron_create` | Create Cron | Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted. | +| `moor_cron_update` | Update Cron | Updates a cron's fields by id. Schedule is validated if provided. | +| `moor_cron_delete` | Delete Cron | Deletes a cron by id. | +| `moor_cron_run` | Run Cron Now | Triggers a cron to run immediately. Requires the project's container to be running. | +| `moor_env_delete` | Delete Environment Variables | Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running. | +| `moor_volume_list` | List Project Volumes | List the named Docker volumes attached to a project. Each entry includes the logical name (per-project handle), the in-container target path, and the actual Docker volume name (for `docker volume ls` / `docker volume inspect` outside moor). | +| `moor_volume_add` | Add Project Volume | Attach a named Docker volume to a project. The volume is created lazily by Docker on first container start; moor stores the mount config (logical name, in-container target, and the generated docker_name like moor--). Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run) — already-running containers keep their existing mounts. | +| `moor_volume_remove` | Remove Project Volume Mount | Detach a named volume from a project's mount config. The underlying Docker volume (and its data) is intentionally preserved — to actually delete the data, use moor_project_delete with purge_volumes:true, or run `docker volume rm ` manually. Takes effect on next container recreate. | +| `moor_file_set` | Set Project File | Declare a file to inject into a project's container. moor writes it via a tar archive PUT right before the container starts, on every recreate, honoring the octal mode (e.g. 0600 for a TLS key). Identified by path — setting the same path again updates its content/mode rather than duplicating. Provide exactly one of content (inline) or env_ref (the name of a project env var to source content from at create time, so a secret stays in the env store instead of plaintext here). Takes effect on next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run). | +| `moor_file_list` | List Project Files | List the declarative files configured for a project. Each entry shows the in-container path, octal mode, and how content is sourced (inline or env). Raw inline content is never returned (it may be large, and env-sourced content lives in the env store). | +| `moor_file_remove` | Remove Project File | Remove a declared file from a project's injection set. The file stops being written on future container recreates; a copy already present in a running container is not deleted until the next recreate. Takes effect on next container recreate. | + +### Credentials & DNS + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_dns_check` | Check Domain DNS | Resolves a domain's A record and reports whether it matches the server's public IP. Useful before pointing a project's domain at the server. | +| `moor_registry_credentials_list` | List Registry Credentials | List all stored Docker registry credentials. Returns metadata only - the raw secret value is never returned by any read path. Each row carries `secret: { configured: true, kind }` where kind is derived from known token prefixes (github_classic_pat, github_fine_grained_pat) or 'unknown'. | +| `moor_registry_credential_get` | Get Registry Credential | Get a single stored registry credential by id. Returns metadata only - the raw secret is never returned. Use this before moor_registry_credential_delete to confirm the hostname you intend to delete. | +| `moor_registry_credential_add` | Add Registry Credential | Store a credential for a Docker registry. The pull path will attach X-Registry-Auth to /images/create whenever an image ref matches this hostname. Hostname must be the bare host as it appears in the image ref (e.g. ghcr.io, docker.io, localhost:5000) - no scheme, no path. Note: the secret value passes through the MCP client and tool-call transport, same security model as moor_env_set; rotate via moor_registry_credential_update if it has been exposed. | +| `moor_registry_credential_update` | Update Registry Credential | Rotate username and/or secret on an existing credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break the pull path. To change hostnames, delete and re-create. Requires at least one of username or secret. Note: the secret value passes through the MCP client on input - same security model as moor_env_set. | +| `moor_registry_credential_delete` | Delete Registry Credential | Delete a stored registry credential. Requires confirm_hostname to match the resolved row's hostname exactly - guards against deleting the wrong row from a stale id. After deletion, pulls for that registry fall back to anonymous. Irreversible. | +| `moor_source_credentials_list` | List Source Credentials | List all stored Git source credentials (HTTPS PATs). Returns metadata only - the raw secret value is never returned by any read path. Multiple credentials can share a hostname (e.g. two github.com rows for different orgs); use label to disambiguate. | +| `moor_source_credential_get` | Get Source Credential | Get a single source credential by id. Returns metadata only. Use this before moor_source_credential_delete to confirm the label you intend to delete. | +| `moor_source_credential_add` | Add Source Credential | Store a Git source credential (HTTPS PAT) for a private repo host. v1 supports HTTPS PATs only; SSH deploy keys may be added in a future version. Hostname must be the bare host as parsed from a Git URL (github.com, gitlab.com, etc.) - no scheme, no path. Multiple credentials can share a hostname; the (hostname, label) pair is unique. For GitHub: use a fine-grained PAT with `Contents: read` (username `x-access-token`), or a classic PAT with `repo` scope. Note: the secret value passes through the MCP client and tool-call transport on input, same security model as moor_env_set. | +| `moor_source_credential_update` | Update Source Credential | Rotate username, secret, label, or expires_at on an existing source credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break in-flight builds. To change hostname, delete and recreate. Requires at least one of username, secret, label, or expires_at. | +| `moor_source_credential_delete` | Delete Source Credential | Delete a stored source credential. Requires confirm_label to match the resolved row's label exactly - protects against deleting the wrong credential on a host that has several (e.g. two github.com rows). Refused with credential_in_use if any project still references this credential. Irreversible. | +| `moor_source_credential_check` | Check Source Credential Access | Run a real `git ls-remote` against the repo URL to verify access. Without source_credential_id, probes anonymously first; if private and exactly one credential matches the host, auto-selects it. With source_credential_id, tests that exact credential. With branch, tests the specific branch (branch_not_found is distinct from auth failure). Discovers default_branch via HEAD symref when no branch is provided. Side effect: updates last_checked_at and last_check_status on the credential row; flips state to failed on a credentialed rejection. | + +### Server & observability + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_stats` | Server Stats | Get server resource usage: load, memory, per-filesystem disk usage (the filesystems the moor container can see, plus any operator-configured monitored host disks via MOOR_MONITORED_DISKS), Docker disk by category (images/containers/volumes/build cache) with reclaimable bytes, and container counts. Note: cpu.percent is load-derived (load avg ÷ cores), not instantaneous CPU; use the `load` field for the same signal with explicit naming. | +| `moor_drain_status` | Drain Status | Read-only: current drain state (enabled, reason, expires_at, clear_after_version) plus counts of active work the operator should wait on before an update. active_work uses the same counter as moor_update_status so the two never disagree. | +| `moor_drain_enable` | Enable Drain Mode | Refuse new builds, deploys, execs, manual cron runs, and terminal upgrades with a 503 carrying { reason, expires_at, hint }. Existing in-flight work runs to completion — drain does NOT kill anything. Scheduled cron ticks during drain write a synthetic 'skipped due to drain' run row instead of executing. Read-only routes (status, logs, runs) keep working. Default TTL is 30 minutes; set ttl_minutes to override. clear_after_version is the updater's hook — when set, the drain auto-clears on boot if the running moor version matches. | +| `moor_drain_disable` | Disable Drain Mode | Explicit operator action to clear drain immediately. Does not kill or restart anything — just removes the gate so new builds/deploys/execs/cron triggers/terminal upgrades succeed again. | +| `moor_db_backup` | DB Backup (snapshot) | Take a SQLite snapshot of moor.db via VACUUM INTO. The file lands next to the main DB as moor.db.backup-. Retention is enforced after each snapshot (keeps the 7 most recent by default; older ones are pruned). After this returns, moor_update_status' db_backup.age_seconds will read close to 0. Use before a manual `docker compose pull moor && up -d` if you don't have MOOR_DB_BACKUP_INTERVAL_HOURS scheduled. | +| `moor_project_stats` | Project Container Stats (live) | Live container stats for one project: CPU percent, memory (excluding page cache, same accounting as `docker stats`), network and block I/O totals, PID count. Single Docker stats snapshot — CPU uses the cpu_stats/precpu_stats delta the daemon already includes. Stopped or never-started projects return running=false with zeroed counters (no 404). | +| `moor_project_history` | Project History (stored) | Stored resource history + lifecycle events for one project over a time window — answers 'what was going on with this project around this case?' (NOT live: use moor_project_stats for a current snapshot). Resource samples are taken ~every minute; CPU is averaged across each interval and network/block reported as rates, both computed from raw counters and reset-aware. Events come from the Docker event stream (start/die/oom/kill/restart) and moor's own state changes. Window defaults to the last `hours` (24); pass from_ms/to_ms (epoch ms) for an exact window. A gap warning means events may be incomplete in that window. | + +### Self-update + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_update_status` | Update status / preflight | Report moor's current version + image digest, the latest available digest on GHCR, active in-flight work counts, DB backup recency, and a safe_to_update boolean. update_available is null (not false) when either the local repo_digest or the registry digest is unknown — never lies by comparing across identifier spaces. unsafe_reasons is a human-readable array; render inline rather than re-deriving from booleans. Read-only diagnostic — does NOT perform any update. | +| `moor_update_apply` | Apply moor update (transient respawner) | Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll via moor_update_audit. Outcomes: success \| failed (pull failed pre-replacement) \| rolled_back (up/health failed, automatic rollback succeeded) \| rollback_failed (rollback also failed — manual recovery needed) \| crashed (no marker after 30-min grace). Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable. | +| `moor_update_audit` | Update history (audit log) | Read-only: recent moor_update_apply attempts and their outcomes. Each row shows audit_id, state (success \| failed \| rolled_back \| rollback_failed \| in_progress \| crashed), duration, digest deltas, backup path, and any error logs. error_log preserves the ORIGINAL apply failure (never overwritten by rollback step details); rollback_error is set only on rollback_failed. Default tail is 4 KiB per log field; pass tail_bytes=0 to omit log bodies entirely (keeps the metadata line and replaces the body with a sized marker), or up to 16384 to read more. | + +### Cleanup + +| Tool | Title | Description | +| --- | --- | --- | +| `moor_cleanup_plan` | Cleanup Plan (dry-run) | Dry-run: list Docker resources that are safe to delete on this host. v1 covers build cache (host-wide prune) and dangling images (per-ID). Returns candidates with reclaimable bytes. Pass the same candidate list to moor_cleanup_execute to actually delete. No state is kept between plan and execute — execute re-validates eligibility against current Docker state. | +| `moor_cleanup_execute` | Cleanup Execute | Delete the candidates returned by moor_cleanup_plan. Server uses only the identifying fields (category + id where applicable) and re-validates eligibility against current Docker state immediately before each delete — Docker state can change between plan and execute. Reclaimable byte estimates from plan are ignored; the server reports the actual freed bytes. Every execute writes an audit row. | + + ## Transport diff --git a/packages/mcp/bin/moor-mcp.js b/packages/mcp/bin/moor-mcp.js new file mode 100755 index 0000000..12fc33b --- /dev/null +++ b/packages/mcp/bin/moor-mcp.js @@ -0,0 +1,2 @@ +#!/usr/bin/env bun +import "../dist/index.js"; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 12e379b..b3557b2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -19,17 +19,21 @@ ], "type": "module", "bin": { - "moor-mcp": "src/index.ts" + "moor-mcp": "bin/moor-mcp.js" }, "files": [ - "src/" + "bin/", + "dist/" ], "publishConfig": { "access": "public" }, "scripts": { "dev": "bun run src/index.ts", - "build": "bun build src/index.ts --compile --outfile moor-mcp" + "docs": "bun run scripts/generate-tool-docs.ts", + "build": "bun build src/index.ts --compile --outfile moor-mcp", + "build:package": "bun build src/index.ts --target bun --outfile dist/index.js --external @cfworker/json-schema --external @modelcontextprotocol/server --external zod", + "prepack": "bun run build:package" }, "dependencies": { "@cfworker/json-schema": "^4.1.1", diff --git a/packages/mcp/scripts/generate-tool-docs.ts b/packages/mcp/scripts/generate-tool-docs.ts new file mode 100644 index 0000000..9ceba38 --- /dev/null +++ b/packages/mcp/scripts/generate-tool-docs.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env bun +// Generates the tool reference table embedded in packages/mcp/README.md. +// +// Approach: runtime import, no live server needed. Each register*Tools function +// only touches its `client` argument inside tool handler closures, never at +// registration time, so we call them with a stub client and a stub server that +// records the name + title + description passed to registerTool. This keeps the +// docs in lockstep with the actual registrations (single source of truth) rather +// than re-parsing source with brittle regexes. +// +// Run: bun run scripts/generate-tool-docs.ts (rewrites README section) +// bun run scripts/generate-tool-docs.ts --check (fails if README is stale) + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { registerCleanupTools } from "../src/tools/cleanup"; +import type { ToolContext } from "../src/tools/context"; +import { registerCredentialTools } from "../src/tools/credentials"; +import { registerEnvTools } from "../src/tools/env"; +import { registerExecTools } from "../src/tools/exec"; +import { registerProjectTools } from "../src/tools/projects"; +import { registerRunTools } from "../src/tools/runs"; +import { registerServerTools } from "../src/tools/server"; +import { registerUpdateTools } from "../src/tools/update"; + +type CapturedTool = { name: string; title: string; description: string }; + +// Minimal stand-in for McpServer: records each registration. The register +// functions type their first arg as McpServer, so we cast at the call site. +function makeCaptureServer(sink: CapturedTool[]) { + return { + registerTool(name: string, config: { title?: string; description?: string }): void { + sink.push({ + name, + title: config.title ?? "", + description: (config.description ?? "").replace(/\s+/g, " ").trim(), + }); + }, + }; +} + +// The register functions never call into the client during registration, so an +// empty stub is enough to collect metadata. +const stubClient = {} as ToolContext; + +// Ordered so the table reads deploy-first, matching the recommended workflow. +const domains: { name: string; register: (s: unknown, c: ToolContext) => void }[] = [ + { name: "Projects", register: registerProjectTools }, + { name: "Deployments & runs", register: registerRunTools }, + { name: "Exec & terminal", register: registerExecTools }, + { name: "Environment, cron, volumes & files", register: registerEnvTools }, + { name: "Credentials & DNS", register: registerCredentialTools }, + { name: "Server & observability", register: registerServerTools }, + { name: "Self-update", register: registerUpdateTools }, + { name: "Cleanup", register: registerCleanupTools }, +]; + +const sections: { domain: string; tools: CapturedTool[] }[] = []; +let total = 0; +for (const { name, register } of domains) { + const tools: CapturedTool[] = []; + register(makeCaptureServer(tools) as never, stubClient); + sections.push({ domain: name, tools }); + total += tools.length; +} + +function renderMarkdown(): string { + const lines: string[] = []; + lines.push( + `The server registers ${total} tools. Regenerate this section with \`bun run docs\` after changing any tool.`, + ); + lines.push(""); + for (const { domain, tools } of sections) { + lines.push(`### ${domain}`); + lines.push(""); + lines.push("| Tool | Title | Description |"); + lines.push("| --- | --- | --- |"); + for (const t of tools) { + const desc = t.description.replace(/\|/g, "\\|"); + const title = t.title.replace(/\|/g, "\\|"); + lines.push(`| \`${t.name}\` | ${title} | ${desc} |`); + } + lines.push(""); + } + return lines.join("\n").trimEnd(); +} + +const BEGIN = ""; +const END = ""; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const readmePath = join(scriptDir, "..", "README.md"); +const readme = readFileSync(readmePath, "utf8"); + +const beginIdx = readme.indexOf(BEGIN); +const endIdx = readme.indexOf(END); +if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) { + console.error(`Could not find generated-section markers in ${readmePath}.`); + console.error(`Add these markers where the table should live:\n${BEGIN}\n${END}`); + process.exit(1); +} + +const before = readme.slice(0, beginIdx + BEGIN.length); +const after = readme.slice(endIdx); +const next = `${before}\n${renderMarkdown()}\n\n${after}`; + +if (process.argv.includes("--check")) { + if (next !== readme) { + console.error("README tool table is stale. Run `bun run docs` and commit."); + process.exit(1); + } + console.log(`README tool table is up to date (${total} tools).`); +} else { + writeFileSync(readmePath, next); + console.log(`Wrote ${total} tools across ${sections.length} domains to README.md.`); +} diff --git a/packages/mcp/src/format.test.ts b/packages/mcp/src/format.test.ts new file mode 100644 index 0000000..e0ce491 --- /dev/null +++ b/packages/mcp/src/format.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { deriveRunStatus, formatBytes, renderDrainState } from "./format"; + +describe("formatBytes", () => { + test("handles invalid and empty byte counts", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(-1)).toBe("0 B"); + expect(formatBytes(Number.NaN)).toBe("0 B"); + }); + + test("uses compact binary units", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(10 * 1024 * 1024)).toBe("10 MB"); + expect(formatBytes(3.25 * 1024 ** 3)).toBe("3.3 GB"); + }); +}); + +describe("deriveRunStatus", () => { + test("uses finished_at and exit_code to derive agent-facing status", () => { + expect(deriveRunStatus({ finished_at: null, exit_code: null })).toBe("running"); + expect(deriveRunStatus({ finished_at: "2026-07-06T12:00:00Z", exit_code: 0 })).toBe("success"); + expect(deriveRunStatus({ finished_at: "2026-07-06T12:00:00Z", exit_code: 2 })).toBe("failed"); + expect(deriveRunStatus({ finished_at: "2026-07-06T12:00:00Z", exit_code: null })).toBe( + "failed", + ); + }); +}); + +describe("renderDrainState", () => { + test("renders disabled drain mode as a single status line", () => { + expect( + renderDrainState({ + enabled: false, + reason: null, + started_at: null, + expires_at: null, + clear_after_version: null, + }), + ).toEqual(["drain: OFF"]); + }); + + test("renders enabled drain mode with auto-clear details", () => { + expect( + renderDrainState({ + enabled: true, + reason: "updating moor", + started_at: "2026-07-06T12:00:00Z", + expires_at: "2026-07-06T12:30:00Z", + clear_after_version: "0.54.0", + }), + ).toEqual([ + "drain: ON (reason: updating moor)", + " started_at: 2026-07-06T12:00:00Z", + " expires_at: 2026-07-06T12:30:00Z (auto-clear)", + " clear_after_version: 0.54.0 (auto-clear on matching boot version)", + ]); + }); +}); diff --git a/packages/mcp/src/format.ts b/packages/mcp/src/format.ts new file mode 100644 index 0000000..b3ce418 --- /dev/null +++ b/packages/mcp/src/format.ts @@ -0,0 +1,100 @@ +import { tailUtf8 } from "./tail-utf8"; + +export type DrainState = { + enabled: boolean; + reason: string | null; + started_at: string | null; + expires_at: string | null; + clear_after_version: string | null; +}; + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const val = bytes / 1024 ** i; + return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`; +} + +export function renderDrainState(s: DrainState): string[] { + if (!s.enabled) return ["drain: OFF"]; + const lines = [`drain: ON (reason: ${s.reason ?? "(none)"})`]; + if (s.started_at) lines.push(` started_at: ${s.started_at}`); + if (s.expires_at) lines.push(` expires_at: ${s.expires_at} (auto-clear)`); + if (s.clear_after_version) { + lines.push( + ` clear_after_version: ${s.clear_after_version} (auto-clear on matching boot version)`, + ); + } + return lines; +} + +// A runs row can be a cron run, a build/manual run, OR a cron run whose cron +// was deleted (cron_id was SET NULL by the FK). The list alone can't tell the +// latter two apart, so labels are honest about ambiguity instead of confidently +// calling NULL cron_id "build." +export function deriveRunStatus(row: { + finished_at: string | null; + exit_code: number | null; +}): "running" | "success" | "failed" { + if (!row.finished_at) return "running"; + return row.exit_code === 0 ? "success" : "failed"; +} + +export function deriveRunType(row: { cron_id: number | null; cron_name: string | null }): string { + if (row.cron_name) return `cron(${row.cron_name})`; + // cron_id IS NULL — could be a genuine build/manual run, or a cron run + // whose cron has since been deleted (ON DELETE SET NULL on the FK). + return "build_or_manual"; +} + +export function formatMsShort(ms: number | null | undefined): string { + if (ms == null) return "—"; + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return `${m}m${s % 60}s`; +} + +export function appendStream( + lines: string[], + name: string, + raw: string, + totalBytes: number, + cap: number, +): void { + if (!raw && totalBytes === 0) return; + if (!raw) { + // API returned an empty string but the stream did emit data (totalBytes > 0 + // is possible when no bytes survived API-side tail cap, though unlikely). + lines.push(`${name}_total_bytes=${totalBytes}`); + return; + } + const { tail, storedBytes, trimmed: mcpTrimmed } = tailUtf8(raw, cap); + const apiTrimmed = totalBytes > storedBytes; + let header: string; + if (mcpTrimmed && apiTrimmed) { + header = `${name} (showing last ${tail.length} chars of ${storedBytes} stored bytes; ${totalBytes} total bytes seen):`; + } else if (mcpTrimmed) { + header = `${name} (showing last ${tail.length} chars of ${storedBytes} total bytes):`; + } else if (apiTrimmed) { + header = `${name} (tail of ${storedBytes} stored from ${totalBytes} total bytes seen):`; + } else { + header = `${name}:`; + } + lines.push(header); + if (cap > 0) lines.push(tail); +} + +export function formatMs(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m${rs}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h${rm}m${rs}s`; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index a2e0ad4..daaaf07 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,18 +1,51 @@ #!/usr/bin/env bun import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server"; -import { z } from "zod"; -import { tailUtf8 } from "./tail-utf8"; +import { + createMoorApiClient, + type FetchLike, + type MoorApiClient, + type Project, + parseErrorMessage, +} from "../../contract/src/index"; +import { registerCleanupTools } from "./tools/cleanup"; +import { registerCredentialTools } from "./tools/credentials"; +import { registerEnvTools } from "./tools/env"; +import { registerExecTools } from "./tools/exec"; +import { registerProjectTools } from "./tools/projects"; +import { registerRunTools } from "./tools/runs"; +import { registerServerTools } from "./tools/server"; +import { registerUpdateTools } from "./tools/update"; // --- Config --- -const baseUrl = (process.env.MOOR_URL || "").replace(/\/$/, ""); -const apiKey = process.env.MOOR_API_KEY || ""; +const config = { + baseUrl: (process.env.MOOR_URL || "").replace(/\/$/, ""), + apiKey: process.env.MOOR_API_KEY || "", +}; -if (!baseUrl || !apiKey) { +if (!config.baseUrl || !config.apiKey) { console.error("MOOR_URL and MOOR_API_KEY environment variables are required"); process.exit(1); } +async function rawResponseRequest( + callClient: (client: MoorApiClient) => Promise, +): Promise { + let rawResponse: Response | undefined; + const fetchRawResponse: FetchLike = async (input, init) => { + rawResponse = await globalThis.fetch(input, init); + return new Response(null, { status: 204 }); + }; + const client = createMoorApiClient({ + ...config, + fetch: fetchRawResponse, + }); + + await callClient(client); + if (!rawResponse) throw new Error("No response received"); + return rawResponse; +} + // --- Startup probe --- // Fail closed: verify URL is reachable AND the bearer token authenticates before // registering tools. Misconfigs surface here with a clear stderr message instead @@ -20,91 +53,47 @@ if (!baseUrl || !apiKey) { { let probeRes: Response; try { - probeRes = await fetch(`${baseUrl}/api/projects`, { - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(5000), - }); + probeRes = await rawResponseRequest((client) => + client.get("/api/projects", { signal: AbortSignal.timeout(5000) }), + ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error(`Cannot reach moor at ${baseUrl}: ${msg}`); + console.error(`Cannot reach moor at ${config.baseUrl}: ${msg}`); console.error("Check MOOR_URL and that moor is running (and tunneled, if remote)."); process.exit(1); } if (probeRes.status === 401) { - console.error(`Authentication failed against ${baseUrl}.`); + console.error(`Authentication failed against ${config.baseUrl}.`); console.error("Check MOOR_API_KEY matches the value in moor's .env on the server."); process.exit(1); } if (probeRes.status === 503) { - console.error(`moor at ${baseUrl} returned 503.`); + console.error(`moor at ${config.baseUrl} returned 503.`); console.error("Likely cause: MOOR_INITIAL_PASSWORD not configured. Set it and restart moor."); process.exit(1); } if (!probeRes.ok) { - console.error(`moor at ${baseUrl} returned ${probeRes.status} on startup probe.`); + console.error(`moor at ${config.baseUrl} returned ${probeRes.status} on startup probe.`); process.exit(1); } } // --- HTTP client --- -function headers(json = false): Record { - const h: Record = { Authorization: `Bearer ${apiKey}` }; - if (json) h["Content-Type"] = "application/json"; - return h; -} - -async function apiGet(path: string) { - return fetch(`${baseUrl}${path}`, { headers: headers() }); -} - -async function apiPost(path: string, body?: unknown) { - return fetch(`${baseUrl}${path}`, { - method: "POST", - headers: headers(body !== undefined), - body: body !== undefined ? JSON.stringify(body) : undefined, - }); -} +const apiResponse = { + get: (path: string) => rawResponseRequest((client) => client.get(path)), + post: (path: string, body?: unknown) => rawResponseRequest((client) => client.post(path, body)), + put: (path: string, body: unknown) => rawResponseRequest((client) => client.put(path, body)), + delete: (path: string) => rawResponseRequest((client) => client.delete(path)), +}; -async function apiPut(path: string, body: unknown) { - return fetch(`${baseUrl}${path}`, { - method: "PUT", - headers: headers(true), - body: JSON.stringify(body), - }); +async function readErrorMessage(res: Response): Promise { + const text = await res.text(); + return parseErrorMessage(text, res.status); } -async function apiDelete(path: string) { - return fetch(`${baseUrl}${path}`, { - method: "DELETE", - headers: headers(), - }); -} - -type Project = { - id: number; - name: string; - status: string; - container_id: string | null; - image_tag: string | null; - domain: string | null; - docker_image: string | null; - github_url: string | null; - // #71: live_* fields are written by the API's status reconciler. - // status above is moor's RECORDED state (only changes on explicit - // start/stop/build/cancel). live_status reflects Docker's view at - // last successful inspect. Differences mean moor missed an external - // change (or the reconciler hasn't run yet). live_error non-null - // means the most recent inspect failed; the live_status / exit_code - // shown is the last successful snapshot. - live_status?: "running" | "stopped" | "error" | "missing" | null; - live_exit_code?: number | null; - live_checked_at?: string | null; - live_error?: string | null; -}; - async function resolveProject(name: string): Promise { - const res = await apiGet("/api/projects"); + const res = await apiResponse.get("/api/projects"); if (!res.ok) throw new Error(`Failed to list projects: ${res.status}`); const projects = (await res.json()) as Project[]; const match = projects.find((p) => p.name === name || String(p.id) === name); @@ -156,142 +145,6 @@ async function readSSE(res: Response): Promise<{ return { logs, error, structuredError }; } -// --- Validators --- - -/** Validate that a string is a github.com URL. Throws with a clear message on failure. - * Stricter than apps/api/routes/docker.ts:validateGithubUrl, which accepts any host - * ending in "github.com" (so "evilgithub.com" slips through). MCP rejects that and - * surfaces the error at create/update time, not at first build/run. */ -function validateGithubUrl(url: string): void { - let host: string; - try { - host = new URL(url).hostname; - } catch { - throw new Error(`github_url is not a valid URL: ${url}`); - } - if (host !== "github.com" && !host.endsWith(".github.com")) { - throw new Error(`github_url must be a github.com URL (got hostname "${host}")`); - } -} - -/** Strict GitHub repo URL validator used by moor_deploy. Stricter than - * validateGithubUrl: requires host = github.com or www.github.com AND a path of - * exactly /owner/repo (with optional .git suffix, optional trailing slash). - * Rejects gist.github.com, the bare root, and /owner/repo/tree/... extras. - * Failed deploys trigger an actual image build/pull, so the up-front check is - * worth being pickier than the create/update wrappers. */ -function validateGithubRepoUrl(url: string): void { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new Error(`github_url is not a valid URL: ${url}`); - } - // The downstream build path (apps/api/docker.ts:buildImageStreaming) appends ".git" - // and a branch ref to whatever URL we forward, so a non-http protocol, query string, - // or fragment quietly mangles the resulting git remote. Reject those up front. - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new Error(`github_url must use http or https (got protocol "${parsed.protocol}")`); - } - if (parsed.search) { - throw new Error(`github_url must not contain query parameters (got "${parsed.search}")`); - } - if (parsed.hash) { - throw new Error(`github_url must not contain a URL fragment (got "${parsed.hash}")`); - } - const host = parsed.hostname; - if (host !== "github.com" && host !== "www.github.com") { - throw new Error(`github_url must use github.com or www.github.com (got "${host}")`); - } - if (!/^\/[^/]+\/[^/]+?(\.git)?\/?$/.test(parsed.pathname)) { - throw new Error( - `github_url must point to /owner/repo (with optional .git); got "${parsed.pathname}"`, - ); - } -} - -/** Validate a 5-field crontab schedule against what apps/api/cron.ts can actually execute. - * Stricter than the scheduler's permissive parser: the scheduler silently never fires - * on bad input, so MCP rejects up-front. Returns an error string or null. */ -const CRON_FIELDS: ReadonlyArray<{ name: string; min: number; max: number }> = [ - { name: "minute", min: 0, max: 59 }, - { name: "hour", min: 0, max: 23 }, - { name: "day-of-month", min: 1, max: 31 }, - { name: "month", min: 1, max: 12 }, - { name: "day-of-week", min: 0, max: 6 }, // 0=Sunday; scheduler does not translate 7 -]; - -// Whitelist each comma-separated part against one of the canonical forms below. -// Anything else (empty parts, leading "-", bare "N/S", "/S", stray characters) is -// rejected. The scheduler at apps/api/cron.ts silently ignores or mis-parses these -// inputs, so the validator must be strict where the scheduler is loose. -const CRON_PART_PATTERNS = [ - /^\*$/, // * - /^(\d+)$/, // N - /^(\d+)-(\d+)$/, // A-B - /^\*\/(\d+)$/, // */S - /^(\d+)-(\d+)\/(\d+)$/, // A-B/S -]; - -function validateCronField(field: string, min: number, max: number, name: string): string | null { - if (field === "*") return null; - if (/[?LW#]/i.test(field)) return `${name}: ?, L, W, # are not supported`; - if (/[a-zA-Z]/.test(field)) - return `${name}: month/day names are not supported, use numeric values`; - - for (const part of field.split(",")) { - if (part === "") return `${name}: empty list element`; - - const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null); - if (!match) return `${name}: invalid expression "${part}"`; - - // Validate captured numbers against per-field bounds and step positivity. - // Capture layout depends on which pattern matched, identified by length. - const groups = match.slice(1); - if (groups.length === 1 && match[0].startsWith("*/")) { - // */S - const step = Number(groups[0]); - if (step <= 0) return `${name}: step must be a positive integer (got "${groups[0]}")`; - } else if (groups.length === 1) { - // N - const n = Number(groups[0]); - if (n < min || n > max) return `${name}: ${n} out of bounds [${min}-${max}]`; - } else if (groups.length === 2) { - // A-B - const a = Number(groups[0]); - const b = Number(groups[1]); - if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`; - if (a > b) return `${name}: range ${a}-${b} is descending`; - } else if (groups.length === 3) { - // A-B/S - const a = Number(groups[0]); - const b = Number(groups[1]); - const step = Number(groups[2]); - if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`; - if (a > b) return `${name}: range ${a}-${b} is descending`; - if (step <= 0) return `${name}: step must be a positive integer (got "${groups[2]}")`; - } - } - return null; -} - -function validateCronSchedule(schedule: string): string | null { - const parts = schedule.trim().split(/\s+/); - if (parts.length !== 5) { - return `schedule must have exactly 5 space-separated fields (got ${parts.length})`; - } - for (let i = 0; i < 5; i++) { - const err = validateCronField( - parts[i], - CRON_FIELDS[i].min, - CRON_FIELDS[i].max, - CRON_FIELDS[i].name, - ); - if (err) return err; - } - return null; -} - // --- MCP Server --- const server = new McpServer({ @@ -301,2798 +154,16 @@ const server = new McpServer({ // --- Tools --- -server.registerTool( - "moor_status", - { - title: "List Projects", - description: - "List all projects managed by Moor. `status` is moor's recorded state (only changes on explicit start/stop/build/cancel). `live_status` is Docker's view at last successful inspect; differences (e.g. recorded='running' live='error') mean moor missed an external change like a host docker stop, crash, or OOM kill. `live_error` non-null means the most recent inspect failed and the live_* values are the last successful snapshot, not necessarily current.", - }, - async () => { - const res = await apiGet("/api/projects"); - if (!res.ok) throw new Error(`Failed: ${res.status}`); - const projects = (await res.json()) as Project[]; - const summary = projects.map((p) => ({ - name: p.name, - status: p.status, - live_status: p.live_status ?? null, - live_exit_code: p.live_exit_code ?? null, - live_checked_at: p.live_checked_at ?? null, - live_error: p.live_error ?? null, - source: p.docker_image || p.github_url || null, - domain: p.domain, - })); - return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; - }, -); +const client = { apiResponse, resolveProject, readErrorMessage, readSSE }; -server.registerTool( - "moor_logs", - { - title: "Get Container Logs", - description: - "Get recent logs from a project's container. Annotates output with state: ok (container running), exited (container is stopped but Docker still has logs), no_container (project never started), or missing (container_id is set but Docker doesn't have it). Throws only on docker_error (Docker daemon 5xx / unreachable) so an operator can distinguish infrastructure failure from app silence — pre-#74 the tool returned empty logs for all of these.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - lines: z.number().optional().default(100).describe("Number of log lines to retrieve"), - }), - }, - async ({ project, lines }) => { - const p = await resolveProject(project); - const res = await apiGet(`/api/projects/${p.id}/logs?tail=${lines}`); - // 502 = API surfaced a Docker daemon failure. Throw so the agent - // gets a tool error, not silent empty logs. - if (res.status === 502) { - const data = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(`Docker error: ${data.error ?? "unknown"}`); - } - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const data = (await res.json()) as { logs: string; state?: string }; - switch (data.state) { - case "no_container": - return { - content: [{ type: "text", text: "(project hasn't been started yet — no container)" }], - }; - case "missing": - return { - content: [ - { - type: "text", - text: "(container_id was recorded but Docker doesn't have it; moor may need to recreate the project)", - }, - ], - }; - case "exited": - return { - content: [ - { - type: "text", - text: `${data.logs || "(no logs captured)"}\n\n(container is exited; logs above are from before)`, - }, - ], - }; - default: - // "ok" or undefined (older API) — render raw. - return { - content: [{ type: "text", text: data.logs || "(no logs)" }], - }; - } - }, -); - -server.registerTool( - "moor_rebuild", - { - title: "Rebuild Project", - description: - "Rebuild a project from source (git pull + docker build) and restart the container. Returns the build output when it finishes. While a build is in flight, the most recent moor_runs entry has finished_at=null — call moor_run_get on its id to tail the live output. Use moor_rebuild for code, Dockerfile, or base-image changes. For env vars / resource limits / port / volume / restart-policy changes, or to recover a crashed container from the existing image, use moor_restart — it skips the build and is much faster.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - no_cache: z.boolean().optional().default(false).describe("Build without Docker cache"), - }), - }, - async ({ project, no_cache }) => { - const p = await resolveProject(project); - const query = no_cache ? "?nocache=true" : ""; - const res = await apiPost(`/api/projects/${p.id}/run${query}`); - // /run can fail BEFORE opening the SSE stream — resolver validation, - // drain mode, invalid URL, credential_not_active. Those land as a - // plain JSON or text body that readSSE walks without matching any - // event:/data: lines, returning empty everything. Without this guard - // the tool would silently report "Rebuild complete." on a failed build. - // Mirrors the existing moor_deploy guard at the /run call site. - if (!res.ok) throw new Error(`[run] ${await res.text()}`); - const { logs, error, structuredError } = await readSSE(res); - // #119: a classified failure (today: source_credential_required) gets - // returned as isError with a structured payload the agent can branch - // on. Unclassified errors keep throwing so the existing UX is preserved. - if (structuredError) { - return { - content: [ - { - type: "text", - text: `rebuild failed: code=${structuredError.code} message=${structuredError.message}`, - }, - ], - structuredContent: structuredError, - isError: true, - }; - } - if (error) throw new Error(error); - return { content: [{ type: "text", text: logs || "Rebuild complete." }] }; - }, -); - -server.registerTool( - "moor_restart", - { - title: "Restart Project", - description: - "Stop and recreate a project's container from its existing image. Does NOT pull from git or rebuild — uses the existing image_tag. Right tool for: applying changed env vars / resource limits / ports / volumes / restart policy, recovering a crashed container, or simply bouncing the process. Wrong tool for: code or Dockerfile changes (use moor_rebuild — those need a new image).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - }), - }, - async ({ project }) => { - const p = await resolveProject(project); - const stopRes = await apiPost(`/api/projects/${p.id}/stop`); - if (!stopRes.ok) throw new Error(`Failed to stop: ${await stopRes.text()}`); - const startRes = await apiPost(`/api/projects/${p.id}/start`); - if (!startRes.ok) throw new Error(`Failed to start: ${await startRes.text()}`); - return { content: [{ type: "text", text: `${p.name} restarted.` }] }; - }, -); - -server.registerTool( - "moor_exec", - { - title: "Execute Command", - description: - "Run a shell command inside a project's running container. Bounded by a per-call timeout (default 10 min, max 1 h). For jobs that may exceed an hour, wait for the async exec tools to ship.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - command: z.string().describe("Shell command to execute"), - timeout_ms: z - .number() - .int() - .min(1000) - .max(3_600_000) - .optional() - .describe( - "Max time in milliseconds before the exec is aborted. Default 600000 (10 min). Max 3600000 (1 h).", - ), - }), - }, - async ({ project, command, timeout_ms }) => { - const p = await resolveProject(project); - const body: Record = { command }; - if (timeout_ms !== undefined) body.timeout_ms = timeout_ms; - const res = await apiPost(`/api/projects/${p.id}/exec`, body); - // The API returns 504 with a structured timeout body when the exec hit - // timeout_ms. Surface the kill outcome in the tool error so the agent can - // tell "the process was actually stopped" from "we just stopped waiting." - if (res.status === 504) { - const t = (await res.json()) as { - timeout_ms: number; - killed: boolean; - killed_pid: string | null; - live_remaining: number; - message: string; - }; - let detail: string; - if (t.killed) { - detail = `Process tree terminated (container pid ${t.killed_pid}).`; - } else if (t.killed_pid !== null) { - detail = `Kill attempted on container pid ${t.killed_pid} but ${t.live_remaining} descendant process(es) still running inside the container.`; - } else { - detail = - "Process kill could not locate the running process — it may still be running inside the container."; - } - throw new Error(`Exec timed out after ${t.timeout_ms}ms. ${detail}`); - } - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const result = (await res.json()) as { - exitCode: number; - stdout: string; - stderr: string; - }; - let text = ""; - if (result.stdout) text += result.stdout; - if (result.stderr) text += `\n[stderr] ${result.stderr}`; - text += `\n[exit code: ${result.exitCode}]`; - return { content: [{ type: "text", text }] }; - }, -); - -server.registerTool( - "moor_env_list", - { - title: "List Environment Variables", - description: "List all environment variables set for a project.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - }), - }, - async ({ project }) => { - const p = await resolveProject(project); - const res = await apiGet(`/api/projects/${p.id}/envs`); - if (!res.ok) throw new Error(`Failed: ${res.status}`); - const vars = (await res.json()) as { key: string; value: string }[]; - if (vars.length === 0) - return { content: [{ type: "text", text: "No environment variables set." }] }; - const text = vars.map((v) => `${v.key}=${v.value}`).join("\n"); - return { content: [{ type: "text", text }] }; - }, -); - -server.registerTool( - "moor_env_set", - { - title: "Set Environment Variables", - description: - "Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - vars: z - .record(z.string(), z.string()) - .describe('Key-value pairs to set, e.g. { "DATABASE_URL": "postgres://..." }'), - }), - }, - async ({ project, vars }) => { - const p = await resolveProject(project); - - // Fetch existing and merge - const existingRes = await apiGet(`/api/projects/${p.id}/envs`); - if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`); - const existing = (await existingRes.json()) as { key: string; value: string }[]; - const merged = new Map(existing.map((v) => [v.key, v.value])); - for (const [key, value] of Object.entries(vars)) { - merged.set(key, value); - } - const allVars = Array.from(merged, ([key, value]) => ({ key, value })); - - const setRes = await apiPut(`/api/projects/${p.id}/envs`, allVars); - if (!setRes.ok) throw new Error(`Failed to set envs: ${await setRes.text()}`); - - const keys = Object.keys(vars).join(", "); - let text = `Set ${keys} on ${p.name}.`; - - // Restart if running - if (p.status === "running") { - await apiPost(`/api/projects/${p.id}/stop`); - const startRes = await apiPost(`/api/projects/${p.id}/start`); - if (!startRes.ok) throw new Error(`Set vars but failed to restart: ${await startRes.text()}`); - text += " Container restarted."; - } - - return { content: [{ type: "text", text }] }; - }, -); - -server.registerTool( - "moor_stats", - { - title: "Server Stats", - description: - "Get server resource usage: load, memory, per-filesystem disk usage (the filesystems the moor container can see, plus any operator-configured monitored host disks via MOOR_MONITORED_DISKS), Docker disk by category (images/containers/volumes/build cache) with reclaimable bytes, and container counts. Note: cpu.percent is load-derived (load avg ÷ cores), not instantaneous CPU; use the `load` field for the same signal with explicit naming.", - }, - async () => { - const res = await apiGet("/api/server/stats"); - if (!res.ok) throw new Error(`Failed: ${res.status}`); - const s = (await res.json()) as { - hostname: string; - os: string; - uptime: string; - cpu: { percent: number; cores: number }; - load?: { one_min: number; cores: number; normalized_percent: number }; - memory: { total: string; used: string; percent: number }; - disk: { total: string; used: string; percent: number }; - disks?: { mount: string; total: string; used: string; percent: number; label?: string }[]; - containers: { running: number; total: number }; - docker?: { - images: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number }; - containers: { - bytes: number; - reclaimable_bytes: number; - count: number; - stopped_count: number; - }; - volumes: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number }; - build_cache: { bytes: number; reclaimable_bytes: number; count: number }; - } | null; - }; - const lines = [ - `Host: ${s.hostname}`, - `OS: ${s.os}`, - `Uptime: ${s.uptime}`, - `CPU: ${s.cpu.percent}% (${s.cpu.cores} cores) — load-derived, not instantaneous`, - ]; - if (s.load) { - lines.push( - `Load (1m): ${s.load.one_min.toFixed(2)} on ${s.load.cores} cores (${s.load.normalized_percent}%)`, - ); - } - lines.push(`Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`); - const disks = s.disks?.length ? s.disks : [{ mount: "/", ...s.disk }]; - for (const d of disks) { - const name = d.label ? `${d.label} (${d.mount})` : `Disk ${d.mount}`; - lines.push(`${name}: ${d.used} / ${d.total} (${d.percent}%)`); - } - lines.push(`Containers: ${s.containers.running} running / ${s.containers.total} total`); - if (s.docker) { - const d = s.docker; - lines.push( - "Docker disk:", - ` Images: ${formatBytes(d.images.bytes)} (${formatBytes(d.images.reclaimable_bytes)} reclaimable, ${d.images.unused_count}/${d.images.count} unused)`, - ` Containers: ${formatBytes(d.containers.bytes)} (${formatBytes(d.containers.reclaimable_bytes)} reclaimable, ${d.containers.stopped_count}/${d.containers.count} stopped)`, - ` Volumes: ${formatBytes(d.volumes.bytes)} (${formatBytes(d.volumes.reclaimable_bytes)} reclaimable, ${d.volumes.unused_count}/${d.volumes.count} unused)`, - ` Build cache: ${formatBytes(d.build_cache.bytes)} (${formatBytes(d.build_cache.reclaimable_bytes)} reclaimable, ${d.build_cache.count} entries)`, - ); - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_update_status", - { - title: "Update status / preflight", - description: - "Report moor's current version + image digest, the latest available digest on GHCR, active in-flight work counts, DB backup recency, and a safe_to_update boolean. update_available is null (not false) when either the local repo_digest or the registry digest is unknown — never lies by comparing across identifier spaces. unsafe_reasons is a human-readable array; render inline rather than re-deriving from booleans. Read-only diagnostic — does NOT perform any update.", - }, - async () => { - const res = await apiGet("/api/server/update-status"); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const s = (await res.json()) as { - current: { - version: string; - image_id: string | null; - repo_digest: string | null; - started_at: string; - }; - available: { - latest_tag: string; - latest_digest: string | null; - update_available: boolean | null; - registry_error: string | null; - }; - active_work: { - builds_in_flight: number; - execs_in_flight: number; - crons_in_flight: number; - terminals_open: number; - }; - db_backup: { - last_backup_at: string | null; - age_seconds: number | null; - location: string | null; - }; - safe_to_update: boolean; - unsafe_reasons: string[]; - recommended_command: string; - }; - const lines: string[] = []; - lines.push(`moor ${s.current.version} (image_id: ${s.current.image_id ?? "unknown"})`); - lines.push( - `repo_digest: ${s.current.repo_digest ?? "(none — locally built or stale inspect)"}`, - ); - - if (s.available.update_available === true) { - lines.push(`update AVAILABLE → latest: ${s.available.latest_digest}`); - } else if (s.available.update_available === false) { - lines.push(`up to date (latest: ${s.available.latest_digest})`); - } else { - // null — explain WHICH side is unknown. - const why = s.available.registry_error - ? `registry unreachable: ${s.available.registry_error}` - : s.current.repo_digest === null - ? "no local repo_digest (built locally?)" - : "comparison unavailable"; - lines.push(`update availability unknown — ${why}`); - } - - lines.push( - `active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`, - ); - - if (s.safe_to_update) { - lines.push("safe_to_update: YES"); - } else { - lines.push("safe_to_update: NO"); - for (const r of s.unsafe_reasons) lines.push(` - ${r}`); - } - lines.push(`recommended: ${s.recommended_command}`); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -// #79: drain mode. Operator-facing primitive that gates new work-against- -// container actions (deploys, builds, async/sync execs, manual cron -// triggers, terminal upgrades) so an upgrade can wait for in-flight work -// to complete cleanly. Drain refuses NEW work; it never kills in-flight -// work. The TTL is load-bearing — every refusal carries expires_at and -// the row auto-clears at expiry so a forgotten drain doesn't lock moor -// forever. - -type DrainStateResponse = { - state: { - enabled: boolean; - reason: string | null; - started_at: string | null; - expires_at: string | null; - clear_after_version: string | null; - }; -}; - -type DrainStatusResponse = DrainStateResponse & { - active_work: { - builds_in_flight: number; - execs_in_flight: number; - crons_in_flight: number; - terminals_open: number; - }; -}; - -function renderDrainState(s: DrainStateResponse["state"]): string[] { - if (!s.enabled) return ["drain: OFF"]; - const lines = [`drain: ON (reason: ${s.reason ?? "(none)"})`]; - if (s.started_at) lines.push(` started_at: ${s.started_at}`); - if (s.expires_at) lines.push(` expires_at: ${s.expires_at} (auto-clear)`); - if (s.clear_after_version) { - lines.push( - ` clear_after_version: ${s.clear_after_version} (auto-clear on matching boot version)`, - ); - } - return lines; -} - -server.registerTool( - "moor_drain_status", - { - title: "Drain Status", - description: - "Read-only: current drain state (enabled, reason, expires_at, clear_after_version) plus counts of active work the operator should wait on before an update. active_work uses the same counter as moor_update_status so the two never disagree.", - }, - async () => { - const res = await apiGet("/api/server/drain"); - if (!res.ok) throw new Error(`drain status failed: ${res.status} ${await res.text()}`); - const s = (await res.json()) as DrainStatusResponse; - const lines = renderDrainState(s.state); - lines.push( - `active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`, - ); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_drain_enable", - { - title: "Enable Drain Mode", - description: - "Refuse new builds, deploys, execs, manual cron runs, and terminal upgrades with a 503 carrying { reason, expires_at, hint }. Existing in-flight work runs to completion — drain does NOT kill anything. Scheduled cron ticks during drain write a synthetic 'skipped due to drain' run row instead of executing. Read-only routes (status, logs, runs) keep working. Default TTL is 30 minutes; set ttl_minutes to override. clear_after_version is the updater's hook — when set, the drain auto-clears on boot if the running moor version matches.", - inputSchema: z.object({ - reason: z - .string() - .optional() - .describe( - "Freeform reason shown in every refusal response (e.g. 'preparing for 0.34 upgrade').", - ), - ttl_minutes: z - .number() - .optional() - .describe("Auto-clear after this many minutes. Default 30. Clamped to [0.05 min, 7 days]."), - clear_after_version: z - .string() - .optional() - .describe( - "Optional: on next boot, if the running moor version equals this value, auto-clear the drain. Typically set by the updater path; safe for manual use too.", - ), - }), - }, - async ({ reason, ttl_minutes, clear_after_version }) => { - const res = await apiPost("/api/server/drain/enable", { - reason, - ttl_minutes, - clear_after_version, - }); - if (!res.ok) throw new Error(`drain enable failed: ${res.status} ${await res.text()}`); - const s = (await res.json()) as DrainStateResponse; - return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] }; - }, -); - -server.registerTool( - "moor_drain_disable", - { - title: "Disable Drain Mode", - description: - "Explicit operator action to clear drain immediately. Does not kill or restart anything — just removes the gate so new builds/deploys/execs/cron triggers/terminal upgrades succeed again.", - }, - async () => { - const res = await apiPost("/api/server/drain/disable", {}); - if (!res.ok) throw new Error(`drain disable failed: ${res.status} ${await res.text()}`); - const s = (await res.json()) as DrainStateResponse; - return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] }; - }, -); - -// #90: operator-initiated DB snapshot. Uses VACUUM INTO on the server so -// hot WAL state is captured safely (cp would copy a corrupt-looking file). -// Backups land next to moor.db; retention prunes to the N most recent. -// Pair with MOOR_DB_BACKUP_INTERVAL_HOURS for scheduled snapshots — this -// tool is for taking one right before a manual update. -server.registerTool( - "moor_db_backup", - { - title: "DB Backup (snapshot)", - description: - "Take a SQLite snapshot of moor.db via VACUUM INTO. The file lands next to the main DB as moor.db.backup-. Retention is enforced after each snapshot (keeps the 7 most recent by default; older ones are pruned). After this returns, moor_update_status' db_backup.age_seconds will read close to 0. Use before a manual `docker compose pull moor && up -d` if you don't have MOOR_DB_BACKUP_INTERVAL_HOURS scheduled.", - }, - async () => { - const res = await apiPost("/api/server/backup", {}); - if (!res.ok) throw new Error(`db backup failed: ${res.status} ${await res.text()}`); - const r = (await res.json()) as { path: string; sizeBytes: number; durationMs: number }; - const mb = (r.sizeBytes / (1024 * 1024)).toFixed(2); - return { - content: [ - { - type: "text", - text: `Snapshot written: ${r.path}\nsize: ${r.sizeBytes}B (${mb} MB)\nduration: ${r.durationMs}ms`, - }, - ], - }; - }, -); - -// #80: moor_update_apply — kick off a transient-respawner update of -// moor itself. The respawner runs async; this tool returns the audit_id -// immediately. Poll moor_update_audit (history of attempts) or -// moor_update_status (version change) for the outcome. -// -// Outcomes (encoded in the audit row's `state` field, surfaced by -// moor_update_audit): -// - success : new image is healthy. -// - failed : pull failed before moor was replaced (no rollback). -// - rolled_back : up/health failed; rollback to prev image succeeded. -// - rollback_failed : up/health failed AND rollback also failed — -// operator must investigate (manual recovery). -// - in_progress : respawner still running. -// - crashed : 30-min grace elapsed without a marker -// (respawner died or moor never read the marker). -server.registerTool( - "moor_update_apply", - { - title: "Apply moor update (transient respawner)", - description: - "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll via moor_update_audit. Outcomes: success | failed (pull failed pre-replacement) | rolled_back (up/health failed, automatic rollback succeeded) | rollback_failed (rollback also failed — manual recovery needed) | crashed (no marker after 30-min grace). Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.", - inputSchema: z.object({ - target_digest: z - .string() - .regex(/^sha256:[0-9a-f]{64}$/, "target_digest must be sha256:<64 hex>") - .optional() - .describe( - "Pin the update to this exact image digest. Default: the registry's current `:latest` digest from moor_update_status.", - ), - bypass: z - .array(z.enum(["active_work", "unknown_digest"])) - .optional() - .describe( - "Per-blocker bypass. `active_work` accepts that in-flight builds/execs/crons will be interrupted via the shutdown coordinator. `unknown_digest` accepts proceeding when the registry comparison is inconclusive (locally-built image, GHCR unreachable). Backup is mandatory and not in this list.", - ), - }), - }, - async (input) => { - const res = await apiPost("/api/server/update/apply", input ?? {}); - if (res.status === 202) { - const { audit_id } = (await res.json()) as { audit_id: number }; - return { - content: [ - { - type: "text", - text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_audit to watch the outcome, or moor_update_status to watch the version. Possible terminal states: - - success (new image healthy) - - failed (pull failed before moor was replaced) - - rolled_back (up/health failed; automatic rollback succeeded; drain stays on) - - rollback_failed (up/health failed AND rollback failed; manual recovery) - - crashed (no marker after 30-min grace; respawner died) -Recovery: rolled_back means moor is on the previous image again; the failed update is captured in error_log. rollback_failed or crashed mean an operator should investigate (likely manual docker compose up).`, - }, - ], - }; - } - // Error: surface the structured reason so callers can act on it. - const body = (await res.json().catch(() => ({}))) as { - error?: { code: string; reason?: string; unsafe_reasons?: string[] }; - }; - const code = body.error?.code ?? `HTTP ${res.status}`; - const reason = body.error?.reason ?? "no detail"; - const extra = body.error?.unsafe_reasons - ? `\nunsafe_reasons:\n - ${body.error.unsafe_reasons.join("\n - ")}` - : ""; - throw new Error(`moor_update_apply refused [${code}]: ${reason}${extra}`); - }, -); - -// #80 PR #6: moor_update_audit — recent history of moor_update_apply -// attempts. Read-only diagnostic; the orchestration lives entirely on -// the moor side. Tail-truncates error_log / rollback_error per-field -// so a crashed update with a long error doesn't blow up the token -// budget; opt in to a larger tail when actively debugging. -// -// Rendering helpers (shortDigest / fmtDuration / tailLog / renderAuditRow -// / renderAuditList) live in ./update-audit-render and are unit-tested -// directly — this tool is a thin shell around them. -import { MAX_LOG_TAIL_BYTES, renderAuditList, type UpdateAuditApiRow } from "./update-audit-render"; - -server.registerTool( - "moor_update_audit", - { - title: "Update history (audit log)", - description: - "Read-only: recent moor_update_apply attempts and their outcomes. Each row shows audit_id, state (success | failed | rolled_back | rollback_failed | in_progress | crashed), duration, digest deltas, backup path, and any error logs. error_log preserves the ORIGINAL apply failure (never overwritten by rollback step details); rollback_error is set only on rollback_failed. Default tail is 4 KiB per log field; pass tail_bytes=0 to omit log bodies entirely (keeps the metadata line and replaces the body with a sized marker), or up to 16384 to read more.", - inputSchema: z.object({ - limit: z - .number() - .int() - .min(1) - .max(200) - .optional() - .describe("How many most-recent attempts to return. Default 20, max 200."), - tail_bytes: z - .number() - .int() - .min(0) - .max(MAX_LOG_TAIL_BYTES) - .optional() - .describe( - "Max bytes of error_log and rollback_error returned inline per row. Default 4096 (4 KiB). 0 to omit log bodies entirely; 16384 max.", - ), - }), - }, - async ({ limit, tail_bytes }) => { - const qs = new URLSearchParams(); - if (limit !== undefined) qs.set("limit", String(limit)); - const path = qs.toString() - ? `/api/server/update/audit?${qs.toString()}` - : "/api/server/update/audit"; - const res = await apiGet(path); - if (!res.ok) throw new Error(`update audit failed: ${res.status} ${await res.text()}`); - const { rows } = (await res.json()) as { rows: UpdateAuditApiRow[] }; - return { - content: [{ type: "text", text: renderAuditList(rows, { tail_bytes }) }], - }; - }, -); - -server.registerTool( - "moor_cleanup_plan", - { - title: "Cleanup Plan (dry-run)", - description: - "Dry-run: list Docker resources that are safe to delete on this host. v1 covers build cache (host-wide prune) and dangling images (per-ID). Returns candidates with reclaimable bytes. Pass the same candidate list to moor_cleanup_execute to actually delete. No state is kept between plan and execute — execute re-validates eligibility against current Docker state.", - inputSchema: z.object({ - scope: z - .array(z.enum(["build_cache", "dangling_image"])) - .optional() - .describe("Subset of categories to plan. Defaults to all v1 categories."), - }), - }, - async ({ scope }) => { - const res = await apiPost("/api/server/cleanup/plan", { scope }); - if (!res.ok) throw new Error(`plan failed: ${res.status} ${await res.text()}`); - const data = (await res.json()) as { - candidates: Array< - | { category: "build_cache"; reclaimable_bytes: number; label: string } - | { - category: "dangling_image"; - id: string; - reclaimable_bytes: number; - repo_tags: string[]; - label: string; - } - >; - total_reclaimable_bytes: number; - }; - if (data.candidates.length === 0) { - return { content: [{ type: "text", text: "Nothing to clean up." }] }; - } - const lines = [ - `${data.candidates.length} candidate(s), total reclaimable: ${formatBytes(data.total_reclaimable_bytes)}.`, - "Pass the candidates_json block below back to moor_cleanup_execute to delete.", - "", - ]; - for (const c of data.candidates) { - if (c.category === "build_cache") { - lines.push( - `build_cache [${c.label}] — ${formatBytes(c.reclaimable_bytes)} reclaimable (host-wide prune)`, - ); - } else { - const tags = c.repo_tags.length > 0 ? ` tags=${c.repo_tags.join(",")}` : ""; - lines.push( - `dangling_image [${c.label}] id=${c.id} ${formatBytes(c.reclaimable_bytes)}${tags}`, - ); - } - } - // Emit candidates_json so the agent doesn't have to reconstruct identifiers - // from the prose lines above. The execute side ignores extra fields, so - // passing the whole candidate objects (label, reclaimable_bytes, etc.) is - // safe — server re-validates eligibility and computes actual freed bytes. - lines.push("", "candidates_json:", JSON.stringify(data.candidates, null, 2)); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_cleanup_execute", - { - title: "Cleanup Execute", - description: - "Delete the candidates returned by moor_cleanup_plan. Server uses only the identifying fields (category + id where applicable) and re-validates eligibility against current Docker state immediately before each delete — Docker state can change between plan and execute. Reclaimable byte estimates from plan are ignored; the server reports the actual freed bytes. Every execute writes an audit row.", - inputSchema: z.object({ - candidates: z - .array( - z.union([ - z.object({ category: z.literal("build_cache") }).passthrough(), - z - .object({ category: z.literal("dangling_image"), id: z.string().min(1) }) - .passthrough(), - ]), - ) - .min(1) - .describe("Candidates from moor_cleanup_plan. Extra fields are ignored server-side."), - }), - }, - async ({ candidates }) => { - const res = await apiPost("/api/server/cleanup/execute", { candidates }); - if (!res.ok) throw new Error(`execute failed: ${res.status} ${await res.text()}`); - const data = (await res.json()) as { - audit_id: number; - total_reclaimed_bytes: number; - results: Array< - | { category: "build_cache"; reclaimed_bytes: number; error: string | null } - | { - category: "dangling_image"; - id: string; - reclaimed_bytes: number; - error: string | null; - } - >; - }; - const lines = [ - `audit_id=${data.audit_id} total_reclaimed=${formatBytes(data.total_reclaimed_bytes)}`, - "", - ]; - for (const r of data.results) { - const status = r.error ? `ERROR: ${r.error}` : "ok"; - if (r.category === "build_cache") { - lines.push(`build_cache: reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`); - } else { - lines.push( - `dangling_image id=${r.id} reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`, - ); - } - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_project_stats", - { - title: "Project Container Stats (live)", - description: - "Live container stats for one project: CPU percent, memory (excluding page cache, same accounting as `docker stats`), network and block I/O totals, PID count. Single Docker stats snapshot — CPU uses the cpu_stats/precpu_stats delta the daemon already includes. Stopped or never-started projects return running=false with zeroed counters (no 404).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - }), - }, - async ({ project }) => { - const p = await resolveProject(project); - const res = await apiGet(`/api/projects/${p.id}/container-stats`); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const s = (await res.json()) as { - running: boolean; - cpu_percent: number; - memory_bytes: number; - memory_limit_bytes: number; - memory_percent: number; - network_rx_bytes: number; - network_tx_bytes: number; - block_read_bytes: number; - block_write_bytes: number; - pids: number; - }; - if (!s.running) { - return { - content: [{ type: "text", text: `${p.name}: not running (zeroed counters returned).` }], - }; - } - const memLimit = s.memory_limit_bytes > 0 ? formatBytes(s.memory_limit_bytes) : "unlimited"; - const lines = [ - `${p.name}: CPU ${s.cpu_percent}% | Memory ${formatBytes(s.memory_bytes)} / ${memLimit} (${s.memory_percent}%) | PIDs ${s.pids}`, - `Network: rx ${formatBytes(s.network_rx_bytes)} / tx ${formatBytes(s.network_tx_bytes)}`, - `Block I/O: read ${formatBytes(s.block_read_bytes)} / write ${formatBytes(s.block_write_bytes)}`, - ]; - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); - const val = bytes / 1024 ** i; - return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`; -} - -server.registerTool( - "moor_project_history", - { - title: "Project History (stored)", - description: - "Stored resource history + lifecycle events for one project over a time window — answers 'what was going on with this project around this case?' (NOT live: use moor_project_stats for a current snapshot). Resource samples are taken ~every minute; CPU is averaged across each interval and network/block reported as rates, both computed from raw counters and reset-aware. Events come from the Docker event stream (start/die/oom/kill/restart) and moor's own state changes. Window defaults to the last `hours` (24); pass from_ms/to_ms (epoch ms) for an exact window. A gap warning means events may be incomplete in that window.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - hours: z - .number() - .optional() - .describe("Lookback window in hours (default 24). Ignored if from_ms/to_ms are given."), - from_ms: z.number().optional().describe("Window start, epoch milliseconds"), - to_ms: z.number().optional().describe("Window end, epoch milliseconds"), - }), - }, - async ({ project, hours, from_ms, to_ms }) => { - const p = await resolveProject(project); - const to = to_ms ?? Date.now(); - const from = from_ms ?? to - (hours ?? 24) * 3_600_000; - const res = await apiGet(`/api/projects/${p.id}/stats/history?from=${from}&to=${to}`); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const h = (await res.json()) as { - from_ms: number; - to_ms: number; - events: Array<{ occurred_at_ms: number; source: string; action: string }>; - summary: { - sample_count: number; - running_sample_count: number; - cpu_percent_avg: number | null; - cpu_percent_max: number | null; - mem_bytes_max: number | null; - net_rx_bytes_total: number; - net_tx_bytes_total: number; - event_counts: Record; - has_gap: boolean; - }; - }; - const s = h.summary; - const windowH = Math.round(((h.to_ms - h.from_ms) / 3_600_000) * 10) / 10; - const lines = [ - `${p.name} history — window ~${windowH}h${s.has_gap ? " [⚠ event gap recorded: events may be incomplete]" : ""}`, - `Samples: ${s.sample_count} total, ${s.running_sample_count} running`, - `CPU: avg ${s.cpu_percent_avg ?? "n/a"}% / max ${s.cpu_percent_max ?? "n/a"}%`, - `Memory: max ${s.mem_bytes_max !== null ? formatBytes(s.mem_bytes_max) : "n/a"}`, - `Network: in ${formatBytes(s.net_rx_bytes_total)} / out ${formatBytes(s.net_tx_bytes_total)}`, - ]; - const counts = Object.entries(s.event_counts); - if (counts.length > 0) { - lines.push(`Events: ${counts.map(([a, n]) => `${a} ${n}`).join(", ")}`); - } - const recent = h.events.slice(-8); - if (recent.length > 0) { - lines.push("Recent events:"); - for (const e of recent) { - lines.push(` ${new Date(e.occurred_at_ms).toISOString()} ${e.action} (${e.source})`); - } - } - if (s.sample_count === 0 && h.events.length === 0) { - lines.push("(no stored history in this window)"); - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_project_get", - { - title: "Get Project", - description: - "Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - }), - }, - async ({ project }) => { - const p = await resolveProject(project); - return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] }; - }, -); - -server.registerTool( - "moor_project_create", - { - title: "Create Project", - description: - "Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild (or moor_deploy in a future release) to bring it up.", - inputSchema: z.object({ - name: z - .string() - .regex( - /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, - "name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -", - ) - .describe("Project name (used as the container name suffix: moor-)"), - github_url: z - .string() - .optional() - .describe("github.com URL; mutually exclusive with docker_image"), - docker_image: z - .string() - .optional() - .describe("Docker image reference (e.g. nginx:latest); mutually exclusive with github_url"), - branch: z.string().optional().describe("Git branch (default: main, for github_url projects)"), - dockerfile: z - .string() - .optional() - .describe("Dockerfile path within the repo (default: Dockerfile)"), - domain: z.string().optional().describe("Public domain to route to this container via Caddy"), - domain_port: z - .number() - .int() - .positive() - .optional() - .describe("Container port Caddy should forward to (required if domain is set)"), - restart_policy: z - .enum(["no", "on-failure", "always", "unless-stopped"]) - .optional() - .describe("Docker restart policy (default: unless-stopped)"), - memory_limit_mb: z - .number() - .int() - .min(6) - .optional() - .describe( - "Max RAM in MB (also caps swap to the same value so the container can't burn through host swap). Min 6 (Docker's floor), max host total memory. Omit for unbounded. Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run).", - ), - cpus: z - .number() - .min(0.001) - .optional() - .describe( - "Max CPU cores. Fractional values OK (e.g. 0.5 = half a core). Min 0.001 (anything smaller rounds to Docker NanoCpus=0, which means unlimited — use omit for that). Max host core count. Takes effect on container recreate.", - ), - volumes: z - .array( - z.object({ - name: z.string().min(1).describe("Logical volume name (unique per project)"), - target: z - .string() - .min(1) - .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"), - }), - ) - .optional() - .describe( - "Named Docker volumes to attach. Each entry creates a per-project volume (stored as moor--) and mounts it at the given target on next container recreate. Data survives container/project rebuilds unless explicitly purged via project delete with purge_volumes=true.", - ), - source_credential_id: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe( - "For github_url projects: pin the source credential row (from moor_source_credential_add) the build path should use. Build synthesizes the credentialed clone URL in memory; the secret is never stored on the project. Ignored when docker_image is set; save-time validation is structural only (id exists).", - ), - command: z - .array(z.string()) - .nullable() - .optional() - .describe( - 'Override the image\'s default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Lets a stock image run a custom command with no throwaway Dockerfile. Omit to keep the image default; pass [] or null to clear a previously-set override. Applies on container recreate.', - ), - entrypoint: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Override the image's ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.", - ), - }), - }, - async (input) => { - const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0); - if (sources !== 1) { - throw new Error("Provide exactly one of github_url or docker_image"); - } - if (input.github_url) validateGithubUrl(input.github_url); - - const { volumes, ...createBody } = input; - const res = await apiPost("/api/projects", createBody); - if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`); - const project = (await res.json()) as { id: number }; - - // Volumes are a separate endpoint so the API stays single-concern. Loop - // through them; if any one fails, report what landed and what didn't. - const volumeFailures: Array<{ name: string; error: string }> = []; - const volumeCreated: string[] = []; - if (volumes && volumes.length > 0) { - for (const v of volumes) { - const vRes = await apiPost(`/api/projects/${project.id}/volumes`, v); - if (vRes.ok) volumeCreated.push(v.name); - else volumeFailures.push({ name: v.name, error: await vRes.text() }); - } - } - - const lines = [JSON.stringify(project, null, 2)]; - if (volumeCreated.length > 0) { - lines.push(`\nCreated volumes: ${volumeCreated.join(", ")}`); - } - if (volumeFailures.length > 0) { - lines.push( - `\nVolume failures (project was still created): ${volumeFailures - .map((f) => `${f.name}: ${f.error}`) - .join("; ")}`, - ); - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_project_update", - { - title: "Update Project", - description: - "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately. Resource-limit changes (memory_limit_mb, cpus) take effect on the next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run) — an already-running container keeps its existing limits.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID to update"), - name: z - .string() - .regex( - /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, - "name must start alphanumeric; allowed: a-z A-Z 0-9 _ -", - ) - .optional(), - github_url: z.string().optional(), - docker_image: z.string().optional(), - branch: z.string().optional(), - dockerfile: z.string().optional(), - domain: z.string().optional(), - domain_port: z.number().int().positive().optional(), - restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(), - memory_limit_mb: z - .number() - .int() - .min(6) - .nullable() - .optional() - .describe( - "Max RAM in MB. Pass null to clear (return to unbounded). Min 6, max host total memory. Takes effect on container recreate.", - ), - cpus: z - .number() - .min(0.001) - .nullable() - .optional() - .describe( - "Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate.", - ), - source_credential_id: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe( - "Pin (or unlink, by passing null) the source credential the build path should use for this github_url project. Switching to docker_image force-clears the id regardless of input. Save-time validation is structural only; host-mismatch / not-active is enforced at build time.", - ), - command: z - .array(z.string()) - .nullable() - .optional() - .describe( - 'Override the image\'s default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Pass [] or null to clear the override and return to the image default. Takes effect on container recreate.', - ), - entrypoint: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Override the image's ENTRYPOINT as an argv array. Pass [] or null to clear. Takes effect on container recreate.", - ), - }), - }, - async (input) => { - const { project, ...updates } = input; - if (Object.keys(updates).length === 0) { - throw new Error("Provide at least one field to update"); - } - if (updates.github_url && updates.docker_image) { - throw new Error("Cannot set both github_url and docker_image in the same update"); - } - if (updates.github_url) validateGithubUrl(updates.github_url); - - const p = await resolveProject(project); - const res = await apiPut(`/api/projects/${p.id}`, updates); - if (!res.ok) throw new Error(`Failed to update project: ${await res.text()}`); - const updated = await res.json(); - return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] }; - }, -); - -server.registerTool( - "moor_project_delete", - { - title: "Delete Project", - description: - "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible. Named Docker volumes are preserved by default (data survives so a recreated project can remount them); pass purge_volumes: true to also delete the underlying Docker volumes — that deletion is also irreversible.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID to delete"), - confirm_name: z - .string() - .describe( - "Must equal the resolved project's name. Guards against deleting the wrong project.", - ), - purge_volumes: z - .boolean() - .optional() - .default(false) - .describe( - "Also delete the underlying Docker volumes (their data). Default false: project gone, volumes (and their data) preserved. The volume metadata is cleaned up either way; this flag only controls whether the data goes too.", - ), - }), - }, - async ({ project, confirm_name, purge_volumes }) => { - const p = await resolveProject(project); - if (confirm_name !== p.name) { - throw new Error( - `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`, - ); - } - const qs = purge_volumes ? "?purge_volumes=true" : ""; - const res = await apiDelete(`/api/projects/${p.id}${qs}`); - if (!res.ok) { - const text = await res.text(); - try { - const parsed = JSON.parse(text); - if (parsed?.message) throw new Error(parsed.message); - } catch { - // not json - } - throw new Error(`Failed to delete project: ${text}`); - } - // 204 No Content (no purge or no volumes) vs 200 JSON (purge with results) - if (res.status === 204) { - return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] }; - } - const body = (await res.json()) as { volumes_purged?: number }; - return { - content: [ - { - type: "text", - text: `Deleted project ${p.name} (id=${p.id}). Purged ${body.volumes_purged ?? 0} Docker volume(s).`, - }, - ], - }; - }, -); - -server.registerTool( - "moor_cron_create", - { - title: "Create Cron", - description: - "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - name: z.string().min(1).describe("Human-readable name for the cron"), - schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'), - command: z.string().min(1).describe("Shell command to run inside the project's container"), - }), - }, - async ({ project, name, schedule, command }) => { - const err = validateCronSchedule(schedule); - if (err) throw new Error(`Invalid schedule: ${err}`); - const p = await resolveProject(project); - const res = await apiPost(`/api/projects/${p.id}/crons`, { name, schedule, command }); - if (!res.ok) throw new Error(`Failed to create cron: ${await res.text()}`); - const cron = await res.json(); - return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] }; - }, -); - -server.registerTool( - "moor_cron_update", - { - title: "Update Cron", - description: "Updates a cron's fields by id. Schedule is validated if provided.", - inputSchema: z.object({ - cron_id: z.number().int().positive().describe("Cron ID"), - name: z.string().min(1).optional(), - schedule: z.string().optional(), - command: z.string().min(1).optional(), - enabled: z.boolean().optional().describe("Enable or disable the cron"), - }), - }, - async ({ cron_id, name, schedule, command, enabled }) => { - if (schedule !== undefined) { - const err = validateCronSchedule(schedule); - if (err) throw new Error(`Invalid schedule: ${err}`); - } - const body: Record = {}; - if (name !== undefined) body.name = name; - if (schedule !== undefined) body.schedule = schedule; - if (command !== undefined) body.command = command; - if (enabled !== undefined) body.enabled = enabled ? 1 : 0; - if (Object.keys(body).length === 0) { - throw new Error("Provide at least one field to update"); - } - const res = await apiPut(`/api/crons/${cron_id}`, body); - if (!res.ok) throw new Error(`Failed to update cron: ${await res.text()}`); - const cron = await res.json(); - return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] }; - }, -); - -server.registerTool( - "moor_cron_delete", - { - title: "Delete Cron", - description: "Deletes a cron by id.", - inputSchema: z.object({ - cron_id: z.number().int().positive().describe("Cron ID"), - }), - }, - async ({ cron_id }) => { - const res = await apiDelete(`/api/crons/${cron_id}`); - if (!res.ok) throw new Error(`Failed to delete cron: ${await res.text()}`); - // API returns 204 whether or not the row existed; phrase the response so it - // doesn't claim a row was removed when it might already have been gone. - return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] }; - }, -); - -server.registerTool( - "moor_cron_run", - { - title: "Run Cron Now", - description: - "Triggers a cron to run immediately. Requires the project's container to be running.", - inputSchema: z.object({ - cron_id: z.number().int().positive().describe("Cron ID"), - }), - }, - async ({ cron_id }) => { - const res = await apiPost(`/api/crons/${cron_id}/run`); - if (!res.ok) { - const text = await res.text(); - let message = text; - try { - const parsed = JSON.parse(text); - if (parsed?.error) message = parsed.error; - } catch { - // Not JSON; use raw text - } - throw new Error(message); - } - return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] }; - }, -); - -server.registerTool( - "moor_env_delete", - { - title: "Delete Environment Variables", - description: - "Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - keys: z.array(z.string().min(1)).min(1).describe("Env var keys to remove"), - }), - }, - async ({ project, keys }) => { - const p = await resolveProject(project); - - const existingRes = await apiGet(`/api/projects/${p.id}/envs`); - if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`); - const existing = (await existingRes.json()) as { key: string; value: string }[]; - const existingKeys = new Set(existing.map((v) => v.key)); - - const toDelete = keys.filter((k) => existingKeys.has(k)); - const missing = keys.filter((k) => !existingKeys.has(k)); - - if (toDelete.length === 0) { - const existingList = [...existingKeys].sort().join(", ") || "(none)"; - return { - content: [ - { - type: "text", - text: `No matching keys on ${p.name}. Existing keys: ${existingList}`, - }, - ], - }; - } - - for (const key of toDelete) { - const res = await apiDelete(`/api/projects/${p.id}/envs/${encodeURIComponent(key)}`); - if (!res.ok) throw new Error(`Failed to delete ${key}: ${await res.text()}`); - } - - let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`; - if (missing.length > 0) text += ` (Not present: ${missing.join(", ")}.)`; - - if (p.status === "running") { - await apiPost(`/api/projects/${p.id}/stop`); - const startRes = await apiPost(`/api/projects/${p.id}/start`); - if (!startRes.ok) { - throw new Error(`Deleted vars but failed to restart: ${await startRes.text()}`); - } - text += " Container restarted."; - } - - return { content: [{ type: "text", text }] }; - }, -); - -server.registerTool( - "moor_dns_check", - { - title: "Check Domain DNS", - description: - "Resolves a domain's A record and reports whether it matches the server's public IP. Useful before pointing a project's domain at the server.", - inputSchema: z.object({ - domain: z.string().min(1).describe("Domain to check, e.g. app.example.com"), - }), - }, - async ({ domain }) => { - const res = await apiPost("/api/dns-check", { domain }); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const data = (await res.json()) as { - resolves: boolean; - ip: string | null; - serverIp: string | null; - }; - const lines = [ - `Domain: ${domain}`, - `Resolves: ${data.resolves ? "yes" : "no"}`, - `Resolved IP: ${data.ip ?? "(none)"}`, - `Server IP: ${data.serverIp ?? "(unknown)"}`, - ]; - if (data.ip && data.serverIp) { - lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`); - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -// --- Volumes (#35) --- - -server.registerTool( - "moor_volume_list", - { - title: "List Project Volumes", - description: - "List the named Docker volumes attached to a project. Each entry includes the logical name (per-project handle), the in-container target path, and the actual Docker volume name (for `docker volume ls` / `docker volume inspect` outside moor).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - }), - }, - async ({ project }) => { - const p = await resolveProject(project); - const res = await apiGet(`/api/projects/${p.id}/volumes`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const rows = (await res.json()) as Array<{ - id: number; - name: string; - target: string; - docker_name: string; - }>; - if (rows.length === 0) { - return { content: [{ type: "text", text: `No volumes attached to ${p.name}.` }] }; - } - const lines = rows.map( - (v) => `id=${v.id} name=${v.name} target=${v.target} docker_name=${v.docker_name}`, - ); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_volume_add", - { - title: "Add Project Volume", - description: - "Attach a named Docker volume to a project. The volume is created lazily by Docker on first container start; moor stores the mount config (logical name, in-container target, and the generated docker_name like moor--). Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run) — already-running containers keep their existing mounts.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - name: z - .string() - .min(1) - .describe("Logical volume name (unique per project; alphanumeric/_/-)"), - target: z - .string() - .min(1) - .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"), - }), - }, - async ({ project, name, target }) => { - const p = await resolveProject(project); - const res = await apiPost(`/api/projects/${p.id}/volumes`, { name, target }); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const created = (await res.json()) as { - id: number; - name: string; - target: string; - docker_name: string; - }; - return { - content: [ - { - type: "text", - text: `Attached volume to ${p.name}: id=${created.id}, name=${created.name}, target=${created.target}, docker_name=${created.docker_name}. Mount applies on next container recreate.`, - }, - ], - }; - }, -); - -server.registerTool( - "moor_volume_remove", - { - title: "Remove Project Volume Mount", - description: - "Detach a named volume from a project's mount config. The underlying Docker volume (and its data) is intentionally preserved — to actually delete the data, use moor_project_delete with purge_volumes:true, or run `docker volume rm ` manually. Takes effect on next container recreate.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - volume_id: z.number().int().positive().describe("Volume ID from moor_volume_list"), - }), - }, - async ({ project, volume_id }) => { - const p = await resolveProject(project); - const res = await apiDelete(`/api/projects/${p.id}/volumes/${volume_id}`); - if (res.status === 404) throw new Error(`Volume ${volume_id} not found on project ${p.name}`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const body = (await res.json()) as { docker_name: string; message: string }; - return { content: [{ type: "text", text: body.message }] }; - }, -); - -// --- Declarative file injection --- - -server.registerTool( - "moor_file_set", - { - title: "Set Project File", - description: - "Declare a file to inject into a project's container. moor writes it via a tar archive PUT right before the container starts, on every recreate, honoring the octal mode (e.g. 0600 for a TLS key). Identified by path — setting the same path again updates its content/mode rather than duplicating. Provide exactly one of content (inline) or env_ref (the name of a project env var to source content from at create time, so a secret stays in the env store instead of plaintext here). Takes effect on next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - path: z - .string() - .min(1) - .describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"), - content: z - .string() - .optional() - .describe("Inline file contents. Provide exactly one of content or env_ref."), - env_ref: z - .string() - .optional() - .describe( - "Name of a project env var to source the contents from at create time. Keeps secrets (keys, certs) in the env store instead of plaintext here. Provide exactly one of content or env_ref.", - ), - mode: z - .string() - .optional() - .describe( - "Octal permission string applied in the tar header, e.g. '0600'. Default '0644'.", - ), - }), - }, - async ({ project, path, content, env_ref, mode }) => { - const p = await resolveProject(project); - const body: Record = { path }; - if (content !== undefined) body.content = content; - if (env_ref !== undefined) body.env_ref = env_ref; - if (mode !== undefined) body.mode = mode; - const res = await apiPost(`/api/projects/${p.id}/files`, body); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const saved = (await res.json()) as { - id: number; - path: string; - mode: string; - source: string; - env_ref: string | null; - }; - const verb = res.status === 201 ? "Added" : "Updated"; - return { - content: [ - { - type: "text", - text: `${verb} file on ${p.name}: id=${saved.id}, path=${saved.path}, mode=${saved.mode}, source=${saved.source}${saved.env_ref ? ` (env_ref=${saved.env_ref})` : ""}. Written into the container on next recreate.`, - }, - ], - }; - }, -); - -server.registerTool( - "moor_file_list", - { - title: "List Project Files", - description: - "List the declarative files configured for a project. Each entry shows the in-container path, octal mode, and how content is sourced (inline or env). Raw inline content is never returned (it may be large, and env-sourced content lives in the env store).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - }), - }, - async ({ project }) => { - const p = await resolveProject(project); - const res = await apiGet(`/api/projects/${p.id}/files`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const rows = (await res.json()) as Array<{ - id: number; - path: string; - mode: string; - source: string; - env_ref: string | null; - }>; - if (rows.length === 0) { - return { content: [{ type: "text", text: `No files configured for ${p.name}.` }] }; - } - const lines = rows.map( - (f) => - `id=${f.id} path=${f.path} mode=${f.mode} source=${f.source}${f.env_ref ? ` env_ref=${f.env_ref}` : ""}`, - ); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_file_remove", - { - title: "Remove Project File", - description: - "Remove a declared file from a project's injection set. The file stops being written on future container recreates; a copy already present in a running container is not deleted until the next recreate. Takes effect on next container recreate.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - file_id: z.number().int().positive().describe("File ID from moor_file_list"), - }), - }, - async ({ project, file_id }) => { - const p = await resolveProject(project); - const res = await apiDelete(`/api/projects/${p.id}/files/${file_id}`); - if (res.status === 404) throw new Error(`File ${file_id} not found on project ${p.name}`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - return { - content: [ - { - type: "text", - text: `Removed file ${file_id} from ${p.name}. Applies on next container recreate.`, - }, - ], - }; - }, -); - -// --- Runs history (#37) --- - -// A runs row can be a cron run, a build/manual run, OR a cron run whose cron -// was deleted (cron_id was SET NULL by the FK). The list alone can't tell the -// latter two apart, so labels are honest about ambiguity instead of confidently -// calling NULL cron_id "build." -function deriveRunStatus(row: { - finished_at: string | null; - exit_code: number | null; -}): "running" | "success" | "failed" { - if (!row.finished_at) return "running"; - return row.exit_code === 0 ? "success" : "failed"; -} - -function deriveRunType(row: { cron_id: number | null; cron_name: string | null }): string { - if (row.cron_name) return `cron(${row.cron_name})`; - // cron_id IS NULL — could be a genuine build/manual run, or a cron run - // whose cron has since been deleted (ON DELETE SET NULL on the FK). - return "build_or_manual"; -} - -function formatMsShort(ms: number | null | undefined): string { - if (ms == null) return "—"; - if (ms < 1000) return `${ms}ms`; - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - return `${m}m${s % 60}s`; -} - -server.registerTool( - "moor_runs", - { - title: "List Project Run History", - description: - "Paginated list of cron runs and build runs for a project. Returns one compact line per run (id, type, status, exit code, duration, output byte counts, timestamps) — stdout/stderr bodies are NOT included to avoid blowing token budgets on large build outputs. Use moor_run_get(run_id) to fetch the stored output for a single run (cron rows store full output; build/manual rows store at most a 64 KiB tail with the original total bytes recorded separately).", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - page: z - .number() - .int() - .positive() - .optional() - .default(1) - .describe("Page number (20 runs per page). Default 1."), - }), - }, - async ({ project, page }) => { - const p = await resolveProject(project); - const res = await apiGet(`/api/projects/${p.id}/runs?include_output=false&page=${page}`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const data = (await res.json()) as { - runs: Array<{ - id: number; - cron_id: number | null; - cron_name: string | null; - cron_command: string | null; - started_at: string; - finished_at: string | null; - exit_code: number | null; - duration_ms: number | null; - stdout_bytes: number; - stderr_bytes: number; - stdout_total_bytes?: number; - stderr_total_bytes?: number; - }>; - total: number; - }; - if (data.runs.length === 0) { - return { - content: [{ type: "text", text: `No runs recorded for ${p.name}.` }], - }; - } - const lines: string[] = []; - lines.push( - `${p.name}: ${data.runs.length} run(s) on page ${page}, ${data.total} total. Use moor_run_get(run_id) for stored output (build/manual rows are tail-truncated; total bytes shown below).`, - ); - for (const r of data.runs) { - const type = deriveRunType(r); - const status = deriveRunStatus(r); - const exit = r.exit_code != null ? ` exit=${r.exit_code}` : ""; - const cmd = r.cron_command ? ` cmd="${r.cron_command}"` : ""; - // #65: surface "what was emitted" (total) per byte field. For live or - // already-truncated build runs total > stored; for crons and historical - // build rows they're equal. Showing total is the operationally useful - // number — "what did Docker actually produce" — and stays accurate as a - // build streams in. Fall back to stdout_bytes if the API is old. - const outTotal = r.stdout_total_bytes ?? r.stdout_bytes; - const errTotal = r.stderr_total_bytes ?? r.stderr_bytes; - lines.push( - `id=${r.id} ${type} ${status}${exit} dur=${formatMsShort(r.duration_ms)} stdout=${outTotal}B stderr=${errTotal}B started=${r.started_at}${cmd}`, - ); - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_run_get", - { - title: "Get Run Detail", - description: - "Fetch one cron or build run with its stdout and stderr. Output is tail-truncated (default 8 KiB per stream; max 65536) to keep responses under typical agent token limits. Use tail_bytes=0 for metadata-only.", - inputSchema: z.object({ - run_id: z.number().int().positive().describe("Run ID returned by moor_runs"), - tail_bytes: z - .number() - .int() - .min(0) - .max(65_536) - .optional() - .describe( - "Max bytes of each stream returned inline. Default 8192. Max 65536. Set to 0 for metadata-only.", - ), - }), - }, - async ({ run_id, tail_bytes }) => { - const cap = tail_bytes ?? 8192; - const res = await apiGet(`/api/runs/${run_id}`); - if (res.status === 404) throw new Error(`run_id ${run_id} not found`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const r = (await res.json()) as { - id: number; - cron_id: number | null; - cron_name: string | null; - cron_command: string | null; - started_at: string; - finished_at: string | null; - exit_code: number | null; - duration_ms: number | null; - stdout: string | null; - stderr: string | null; - stdout_total_bytes?: number | null; - stderr_total_bytes?: number | null; - }; - const lines: string[] = []; - const type = deriveRunType(r); - const status = deriveRunStatus(r); - const exit = r.exit_code != null ? ` exit_code=${r.exit_code}` : ""; - lines.push(`run_id=${r.id} ${type} ${status}${exit} duration=${formatMsShort(r.duration_ms)}`); - if (r.cron_command) lines.push(`cron_command: ${r.cron_command}`); - lines.push(`started_at: ${r.started_at}`); - if (r.finished_at) lines.push(`finished_at: ${r.finished_at}`); - // #65: runs.stdout/stderr for build runs is a server-side 64 KiB tail - // (TAIL_CAP_BYTES). Use stdout_total_bytes / stderr_total_bytes when the - // API provides them so appendStream can honestly report "last X of Y". - // For cron rows the stored payload IS the full output, and total == stored. - // Fall back to encoded length for older APIs that don't return the totals. - const stdoutStr = r.stdout ?? ""; - const stderrStr = r.stderr ?? ""; - const enc = new TextEncoder(); - const stdoutTotal = r.stdout_total_bytes ?? enc.encode(stdoutStr).length; - const stderrTotal = r.stderr_total_bytes ?? enc.encode(stderrStr).length; - appendStream(lines, "stdout", stdoutStr, stdoutTotal, cap); - appendStream(lines, "stderr", stderrStr, stderrTotal, cap); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -server.registerTool( - "moor_run_stop", - { - title: "Stop or Cancel a Run", - description: - "Stops an active cron run or cancels an active build/pull run (from moor_rebuild / moor_deploy). Closing the connection to the Docker build/pull endpoint aborts the daemon-side job. Cancellation is only valid during the build/pull streaming phase — once the build finishes and container start has begun, the call returns not_cancellable. Returns one of: cancelled, cancelled_cron, not_cancellable, already_finished, not_active, not_found. These are all expected outcomes, not errors — the tool throws only on unexpected server failures.", - inputSchema: z.object({ - run_id: z.number().int().positive().describe("Run ID from moor_runs"), - }), - }, - async ({ run_id }) => { - const res = await apiPost(`/api/runs/${run_id}/stop`); - // The /stop route returns 200 for cancelled/cancelled_cron and 4xx - // for the rest of the known result categories (with a result field - // either way). All of those are expected outcomes — render them as - // content so the agent can react without try/catch. Only surface as - // an error if the response doesn't fit the documented shape (server - // error, parse failure, etc). - let data: { ok?: boolean; result?: string; error?: string }; - try { - data = (await res.json()) as { ok?: boolean; result?: string; error?: string }; - } catch { - throw new Error(`run_id=${run_id} server error: ${res.status} ${res.statusText}`); - } - if (typeof data.result === "string") { - return { content: [{ type: "text", text: `run_id=${run_id} ${data.result}` }] }; - } - throw new Error( - `run_id=${run_id} unexpected response: status=${res.status} body=${JSON.stringify(data)}`, - ); - }, -); - -server.registerTool( - "moor_deploy", - { - title: "Deploy Project", - description: - "Create-or-update a project end to end: metadata, env vars (merged into existing), and an optional build/run. Default fails if the project already exists; pass update_existing: true to upsert. When run: true (default), waits for the full Docker build/pull and start, which can take minutes for large images. Errors are tagged by the failing step ([create], [update], [set_env], or [run]) and do not roll back earlier steps.", - inputSchema: z.object({ - name: z - .string() - .regex( - /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, - "name must start alphanumeric; allowed chars: a-z A-Z 0-9 _ -", - ) - .describe("Project name (also the container suffix: moor-)"), - github_url: z - .string() - .optional() - .describe( - "GitHub repo URL: host must be github.com or www.github.com, path must be /owner/repo (optional .git). Mutually exclusive with docker_image.", - ), - docker_image: z - .string() - .optional() - .describe( - "Docker image reference (e.g. nginx:latest). Mutually exclusive with github_url.", - ), - branch: z.string().optional().describe("Git branch (API default: main)"), - dockerfile: z - .string() - .optional() - .describe("Dockerfile path in the repo (API default: Dockerfile)"), - domain: z.string().optional().describe("Public domain to route via Caddy"), - domain_port: z - .number() - .int() - .positive() - .optional() - .describe("Container port Caddy should forward to"), - restart_policy: z - .enum(["no", "on-failure", "always", "unless-stopped"]) - .optional() - .describe("Docker restart policy (API default: unless-stopped)"), - memory_limit_mb: z - .number() - .int() - .min(6) - .nullable() - .optional() - .describe( - "Max RAM in MB (also caps swap to the same value). Min 6, max host total memory. Pass null on update to clear. Limits apply on container recreate, which deploy always does when run: true.", - ), - cpus: z - .number() - .min(0.001) - .nullable() - .optional() - .describe( - "Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear.", - ), - volumes: z - .array( - z.object({ - name: z.string().min(1), - target: z.string().min(1), - }), - ) - .optional() - .describe( - "Named Docker volumes to attach. Each entry becomes a per-project volume (stored as moor--) and mounts at the given target on container recreate. On update_existing, additions only — no removals. Data survives container/project rebuilds unless explicitly purged via moor_project_delete with confirm_name (purge_volumes is a separate flag).", - ), - env: z - .record(z.string(), z.string()) - .optional() - .describe( - "Env vars to MERGE into existing project envs. Omit to leave envs untouched. Pass {} for an explicit no-op. Use moor_env_delete to remove keys.", - ), - source_credential_id: z - .number() - .int() - .positive() - .nullable() - .optional() - .describe( - "For github_url projects: pin the source credential row (created via moor_source_credential_add). Build path synthesizes the credentialed clone URL in memory; secret never gets stored on the project row. Pass null to detach without switching source type. Ignored when docker_image is set. Save-time validation is structural only (id exists); host-mismatch / not-active is enforced at build time so configuration can survive transient credential outages.", - ), - command: z - .array(z.string()) - .nullable() - .optional() - .describe( - 'Override the image default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Lets a stock image (e.g. cloudflare/cloudflared) run a custom command with no throwaway Dockerfile. Omit to keep the image default; pass [] or null to clear. Applies on the recreate the run step performs.', - ), - entrypoint: z - .array(z.string()) - .nullable() - .optional() - .describe( - "Override the image ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.", - ), - files: z - .array( - z.object({ - path: z - .string() - .min(1) - .describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"), - content: z - .string() - .optional() - .describe("Inline file contents. Provide exactly one of content or env_ref."), - env_ref: z - .string() - .optional() - .describe( - "Name of a project env var to source the contents from at create time, so a secret (TLS key, token) lives in the env store rather than in plaintext here. Provide exactly one of content or env_ref.", - ), - mode: z - .string() - .optional() - .describe("Octal permission string for the tar header, e.g. '0600'. Default '0644'."), - }), - ) - .optional() - .describe( - "Declarative files to inject into the container before it starts, written on every recreate (additions/updates only — moor_deploy never removes files; use moor_file_remove). Each file's path identifies it; re-deploying the same path updates its content. Honors the octal mode in the tar header (e.g. 0600 for a key).", - ), - run: z - .boolean() - .optional() - .default(true) - .describe( - "Build/pull and start after create/update. Default true. Setting false leaves the container untouched; if envs changed while the container is running, the change will not apply until the next run/restart.", - ), - update_existing: z - .boolean() - .optional() - .default(false) - .describe("Allow updating a project that already exists. Default false (create-only)."), - }), - }, - async (input) => { - // Up-front validation: do strict checks before any side effects. - if (input.github_url) validateGithubRepoUrl(input.github_url); - if (input.github_url && input.docker_image) { - throw new Error("Cannot set both github_url and docker_image"); - } - - // #79: drain-mode preflight. moor_deploy is a composition: by the - // time the run step (Step 3) hits the drain 503 from /api/projects/ - // :id/run, the create/update/volume/env side effects have already - // landed. Check drain server-side BEFORE any writes so a drained - // deploy fails cleanly without leaving partial state. - // - // Skipped when run: false because the no-run mode is metadata-only — - // no container work, so drain doesn't apply. - if (input.run !== false) { - const drainRes = await apiGet("/api/server/drain"); - if (drainRes.ok) { - const { state } = (await drainRes.json()) as { - state: { enabled: boolean; reason: string | null; expires_at: string | null }; - }; - if (state.enabled) { - throw new Error( - `[drain] moor is draining (reason: ${state.reason ?? "(none)"}; expires_at: ${state.expires_at}). Refusing deploy before any project create/update side effects. Use moor_drain_disable to re-enable, or retry after expiry. Pass run: false if you only need metadata changes.`, - ); - } - } - // If the drain endpoint is unreachable (older moor or transient - // failure), don't block the deploy — the per-route gate inside - // /api/projects/:id/run will still catch it before container work - // starts. Preflight is an optimization, not the guarantee. - } - - // Resolve existence and check domain conflicts from a single project list. - const listRes = await apiGet("/api/projects"); - if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`); - const projects = (await listRes.json()) as Project[]; - const existing = projects.find((p) => p.name === input.name); - - if (existing && !input.update_existing) { - throw new Error( - `Project "${input.name}" already exists. Pass update_existing: true to update it.`, - ); - } - - if (!existing) { - const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0); - if (sources !== 1) { - throw new Error("Provide exactly one of github_url or docker_image"); - } - } - - // Normalize once for both the conflict check and the write. The API trims but - // does not lowercase, so " Example.com " vs an existing "example.com" would - // slip past the raw-string pre-check and only surface as a Caddy collision. - const normalizedDomain = - input.domain === undefined ? undefined : input.domain.trim().toLowerCase() || null; - - if (normalizedDomain) { - const conflict = projects.find( - (p) => - p.domain && p.domain.trim().toLowerCase() === normalizedDomain && p.id !== existing?.id, - ); - if (conflict) { - throw new Error( - `Domain "${normalizedDomain}" is already used by project "${conflict.name}" (id=${conflict.id}). Refusing before Caddy reload.`, - ); - } - } - - // Step 1: create or update project metadata. - let projectId: number; - let projectName: string; - if (!existing) { - const createBody: Record = { - name: input.name, - github_url: input.github_url, - docker_image: input.docker_image, - branch: input.branch, - dockerfile: input.dockerfile, - domain: normalizedDomain, - domain_port: input.domain_port, - restart_policy: input.restart_policy, - memory_limit_mb: input.memory_limit_mb, - cpus: input.cpus, - source_credential_id: input.source_credential_id, - command: input.command, - entrypoint: input.entrypoint, - }; - const res = await apiPost("/api/projects", createBody); - if (!res.ok) throw new Error(`[create] ${await res.text()}`); - const created = (await res.json()) as Project; - projectId = created.id; - projectName = created.name; - } else { - // Update only fields explicitly provided. `name` is the lookup key here, - // not a rename target; use moor_project_update for renames. - const updateBody: Record = {}; - if (input.github_url !== undefined) updateBody.github_url = input.github_url; - if (input.docker_image !== undefined) updateBody.docker_image = input.docker_image; - if (input.branch !== undefined) updateBody.branch = input.branch; - if (input.dockerfile !== undefined) updateBody.dockerfile = input.dockerfile; - if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain; - if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port; - if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy; - if (input.memory_limit_mb !== undefined) updateBody.memory_limit_mb = input.memory_limit_mb; - if (input.cpus !== undefined) updateBody.cpus = input.cpus; - if (input.source_credential_id !== undefined) - updateBody.source_credential_id = input.source_credential_id; - if (input.command !== undefined) updateBody.command = input.command; - if (input.entrypoint !== undefined) updateBody.entrypoint = input.entrypoint; - - if (Object.keys(updateBody).length > 0) { - const res = await apiPut(`/api/projects/${existing.id}`, updateBody); - if (!res.ok) throw new Error(`[update] ${await res.text()}`); - } - projectId = existing.id; - projectName = existing.name; - } - - // Step 1.5: add named volumes (additions only — moor_deploy never removes - // volumes, even on update_existing). Mounts apply on next container - // recreate, which the run step below triggers by default. - if (input.volumes && input.volumes.length > 0) { - // Cache the existing list once so we can resolve 409s without re-fetching - // per conflict. Only fetched if a 409 actually occurs. - let existingVolumes: Array<{ name: string; target: string }> | null = null; - for (const v of input.volumes) { - const vRes = await apiPost(`/api/projects/${projectId}/volumes`, v); - if (vRes.ok) continue; - const text = await vRes.text(); - if (vRes.status !== 409) { - throw new Error(`[volumes] failed to add ${v.name}: ${text}`); - } - // 409 is tolerable ONLY if the existing volume matches the requested - // spec exactly (same name, same target). A 409 with a drifted target - // means the operator changed the desired mount and we'd silently - // ignore the change — fail loudly instead. - if (existingVolumes === null) { - const listRes = await apiGet(`/api/projects/${projectId}/volumes`); - if (!listRes.ok) { - throw new Error( - `[volumes] could not resolve 409 on ${v.name}: failed to list existing volumes: ${await listRes.text()}`, - ); - } - existingVolumes = (await listRes.json()) as Array<{ name: string; target: string }>; - } - const match = existingVolumes.find((e) => e.name === v.name); - if (!match) { - // 409 was for some other reason (target collision under a different - // name, or cross-project docker_name collision). Operator must - // intervene. - throw new Error( - `[volumes] conflict adding ${v.name}: ${text} (no existing volume by that name; check for target collision)`, - ); - } - if (match.target !== v.target) { - throw new Error( - `[volumes] conflict adding ${v.name}: existing target "${match.target}" differs from requested "${v.target}". moor_deploy does not change mount targets; use moor_volume_remove + moor_volume_add explicitly.`, - ); - } - // Same name, same target — idempotent re-run, tolerable. - } - } - - // Step 1.6: inject declarative files (additions/updates only — deploy never - // removes files; use moor_file_remove for that). The route upserts by path, - // so re-deploying the same path updates its content. Files are written into - // the container right before start on the recreate the run step triggers. - if (input.files && input.files.length > 0) { - for (const f of input.files) { - const fRes = await apiPost(`/api/projects/${projectId}/files`, f); - if (!fRes.ok) { - throw new Error(`[files] failed to set ${f.path}: ${await fRes.text()}`); - } - } - } - - // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op. - const envEntries = input.env ? Object.entries(input.env) : []; - const envProvided = envEntries.length > 0; - if (envProvided) { - const existingRes = await apiGet(`/api/projects/${projectId}/envs`); - if (!existingRes.ok) { - throw new Error(`[set_env] Failed to read envs: ${existingRes.status}`); - } - const existingEnvs = (await existingRes.json()) as { key: string; value: string }[]; - const merged = new Map(existingEnvs.map((v) => [v.key, v.value])); - for (const [k, v] of envEntries) merged.set(k, v); - const allVars = Array.from(merged, ([key, value]) => ({ key, value })); - const putRes = await apiPut(`/api/projects/${projectId}/envs`, allVars); - if (!putRes.ok) throw new Error(`[set_env] ${await putRes.text()}`); - } - - // Step 3: run, default true. Wait for the full SSE stream like moor_rebuild. - let runLogs = ""; - let runStructuredError: { code: string; message: string } | undefined; - if (input.run) { - const runRes = await apiPost(`/api/projects/${projectId}/run`); - if (!runRes.ok) throw new Error(`[run] ${await runRes.text()}`); - const { logs, error, structuredError } = await readSSE(runRes); - runLogs = logs; - // #119: classified failure (today: source_credential_required) is - // returned as isError below so the agent can branch on the code - // instead of parsing a thrown message. - if (structuredError) { - runStructuredError = structuredError; - } else if (error) { - throw new Error(`[run] ${error}`); - } - } - - const lines: string[] = []; - lines.push( - existing - ? `Updated project ${projectName} (id=${projectId}).` - : `Created project ${projectName} (id=${projectId}).`, - ); - if (envProvided) { - lines.push( - `Merged ${envEntries.length} env var(s): ${envEntries.map(([k]) => k).join(", ")}.`, - ); - } - if (!input.run) { - if (envProvided && existing?.status === "running") { - lines.push( - "Note: project is running; env changes will not take effect until the next run or restart.", - ); - } - } else { - lines.push(""); - lines.push("Build/run output:"); - lines.push(runLogs || "(no output)"); - } - // #119: if the build was classified as auth-failure, return isError - // with the structured payload so the agent can call _check + add a - // credential and retry. The project row exists (create/update already - // committed) so the agent just needs to fix the credential and run - // deploy again with the pinned id. - if (runStructuredError) { - lines.push(""); - lines.push(`Failed: code=${runStructuredError.code} message=${runStructuredError.message}`); - return { - content: [{ type: "text", text: lines.join("\n") }], - structuredContent: { ...runStructuredError, project_id: projectId }, - isError: true, - }; - } - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -// --- Async exec (#34 Phase B) --- - -server.registerTool( - "moor_exec_async", - { - title: "Start Async Exec", - description: - "Run a long-lived command inside a project's container, returning immediately with a run_id. Use moor_exec_status to poll for output and exit code; moor_exec_stop to terminate. Bounded by an optional timeout_ms (default 86400000 = 24h; min 60000 = 1 min; max 86400000). The recorded output is tail-truncated to the last 64 KiB per stream; stdout_total_bytes and stderr_total_bytes report the full pre-truncation byte count.", - inputSchema: z.object({ - project: z.string().describe("Project name or ID"), - command: z.string().min(1).describe("Shell command to execute"), - timeout_ms: z - .number() - .int() - .min(60_000) - .max(86_400_000) - .optional() - .describe( - "Safety timeout in milliseconds. When exceeded, the process tree is terminated and the run is marked timed_out. Default 86400000 (24h). Min 60000. Max 86400000.", - ), - }), - }, - async ({ project, command, timeout_ms }) => { - const p = await resolveProject(project); - const body: Record = { command }; - if (timeout_ms !== undefined) body.timeout_ms = timeout_ms; - const res = await apiPost(`/api/projects/${p.id}/exec/async`, body); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const data = (await res.json()) as { run_id: number }; - return { - content: [ - { - type: "text", - text: `Started async exec on ${p.name}. run_id=${data.run_id}. Use moor_exec_status to poll; moor_exec_stop to terminate.`, - }, - ], - }; - }, -); - -server.registerTool( - "moor_exec_status", - { - title: "Get Async Exec Status", - description: - "Return the current state of an async exec run: state, exit code (when finished), running tail of stdout/stderr (default 8 KiB each inline; the API stores up to 64 KiB), total bytes seen, duration, and any error message. State is one of: running, exited, stopped, timed_out, error. Pass tail_bytes to control how many bytes of each stream are returned inline (0 to 65536; default 8192). The API's 64 KiB-per-stream storage cap is unchanged — tail_bytes only controls what the MCP tool returns to keep responses under typical agent token limits.", - inputSchema: z.object({ - run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"), - tail_bytes: z - .number() - .int() - .min(0) - .max(65_536) - .optional() - .describe( - "Max bytes of each stream (stdout, stderr) returned inline. Default 8192. Max 65536 (the API storage cap). Set to 0 for metadata-only.", - ), - }), - }, - async ({ run_id, tail_bytes }) => { - const cap = tail_bytes ?? 8192; - const res = await apiGet(`/api/exec/${run_id}`); - if (res.status === 404) throw new Error(`run_id ${run_id} not found`); - if (!res.ok) throw new Error(`Failed: ${await res.text()}`); - const data = (await res.json()) as { - id: number; - state: string; - exit_code: number | null; - stdout: string; - stderr: string; - stdout_total_bytes: number; - stderr_total_bytes: number; - duration_ms: number; - command: string; - killed_pid: string | null; - error_message: string | null; - started_at: string; - finished_at: string | null; - }; - const lines: string[] = []; - lines.push( - `run_id=${data.id} state=${data.state} duration=${formatMs(data.duration_ms)}` + - (data.exit_code !== null ? ` exit_code=${data.exit_code}` : ""), - ); - lines.push(`command: ${data.command}`); - if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`); - if (data.error_message) lines.push(`error: ${data.error_message}`); - appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap); - appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap); - return { content: [{ type: "text", text: lines.join("\n") }] }; - }, -); - -function appendStream( - lines: string[], - name: string, - raw: string, - totalBytes: number, - cap: number, -): void { - if (!raw && totalBytes === 0) return; - if (!raw) { - // API returned an empty string but the stream did emit data (totalBytes > 0 - // is possible when no bytes survived API-side tail cap, though unlikely). - lines.push(`${name}_total_bytes=${totalBytes}`); - return; - } - const { tail, storedBytes, trimmed: mcpTrimmed } = tailUtf8(raw, cap); - const apiTrimmed = totalBytes > storedBytes; - let header: string; - if (mcpTrimmed && apiTrimmed) { - header = `${name} (showing last ${tail.length} chars of ${storedBytes} stored bytes; ${totalBytes} total bytes seen):`; - } else if (mcpTrimmed) { - header = `${name} (showing last ${tail.length} chars of ${storedBytes} total bytes):`; - } else if (apiTrimmed) { - header = `${name} (tail of ${storedBytes} stored from ${totalBytes} total bytes seen):`; - } else { - header = `${name}:`; - } - lines.push(header); - if (cap > 0) lines.push(tail); -} - -server.registerTool( - "moor_exec_stop", - { - title: "Stop Async Exec", - description: - "Terminate a running async exec by run_id. Walks the descendant process tree inside the container and sends SIGTERM then SIGKILL. Always transitions the run to a terminal state: state=stopped on clean termination (all descendants gone), state=error if any descendant survived OR if the kill handle was lost (moor restart, missing pidfile). Stop is NOT retry-safe — the kill script removes the pidfile after every attempt, and reparented survivors are unreachable from the original PID.", - inputSchema: z.object({ - run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"), - }), - }, - async ({ run_id }) => { - const res = await apiPost(`/api/exec/${run_id}/stop`); - if (res.status === 404) throw new Error(`run_id ${run_id} not found`); - const data = (await res.json()) as { - ok: boolean; - state: string; - killed_pid: string | null; - live_remaining: number; - message: string; - }; - return { - content: [ - { - type: "text", - text: `run_id=${run_id} state=${data.state} ${data.message}`, - }, - ], - }; - }, -); - -// --- Registry credentials --- -// -// Server-wide credentials used by the pull path to attach -// X-Registry-Auth on /images/create for private images. Read shape -// is write-only: the raw secret never leaves the API. Tool descriptions -// flag that *inputs* (add/update) carry the secret over the tool-call -// path and are visible to the MCP client - same security model as -// moor_env_set. structuredContent mirrors the API metadata shape so -// agents can reason over it without scraping the human text. - -type RegistryCredentialMetadata = { - id: number; - hostname: string; - username: string; - secret: { configured: true; kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown" }; - created_at: string; - updated_at: string; -}; - -function renderCredentialLine(c: RegistryCredentialMetadata): string { - return `id=${c.id} ${c.hostname} user=${c.username} kind=${c.secret.kind} updated=${c.updated_at}`; -} - -server.registerTool( - "moor_registry_credentials_list", - { - title: "List Registry Credentials", - description: - "List all stored Docker registry credentials. Returns metadata only - the raw secret value is never returned by any read path. Each row carries `secret: { configured: true, kind }` where kind is derived from known token prefixes (github_classic_pat, github_fine_grained_pat) or 'unknown'.", - }, - async () => { - const res = await apiGet("/api/server/registry-credentials"); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const data = (await res.json()) as { rows: RegistryCredentialMetadata[] }; - const text = - data.rows.length === 0 - ? "No registry credentials configured. The pull path falls back to anonymous for every registry." - : data.rows.map(renderCredentialLine).join("\n"); - return { - content: [{ type: "text", text }], - structuredContent: { rows: data.rows }, - }; - }, -); - -server.registerTool( - "moor_registry_credential_get", - { - title: "Get Registry Credential", - description: - "Get a single stored registry credential by id. Returns metadata only - the raw secret is never returned. Use this before moor_registry_credential_delete to confirm the hostname you intend to delete.", - inputSchema: z.object({ - id: z - .number() - .int() - .positive() - .describe("Credential id (from moor_registry_credentials_list)"), - }), - }, - async ({ id }) => { - const res = await apiGet(`/api/server/registry-credentials/${id}`); - if (res.status === 404) throw new Error(`credential id=${id} not found`); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const row = (await res.json()) as RegistryCredentialMetadata; - return { - content: [{ type: "text", text: renderCredentialLine(row) }], - structuredContent: row as unknown as Record, - }; - }, -); - -server.registerTool( - "moor_registry_credential_add", - { - title: "Add Registry Credential", - description: - "Store a credential for a Docker registry. The pull path will attach X-Registry-Auth to /images/create whenever an image ref matches this hostname. Hostname must be the bare host as it appears in the image ref (e.g. ghcr.io, docker.io, localhost:5000) - no scheme, no path. Note: the secret value passes through the MCP client and tool-call transport, same security model as moor_env_set; rotate via moor_registry_credential_update if it has been exposed.", - inputSchema: z.object({ - hostname: z - .string() - .describe( - "Bare registry host as parsed from an image ref. Examples: ghcr.io, docker.io, localhost:5000, registry.example.com:5000. No scheme, no path.", - ), - username: z - .string() - .describe("Registry username. For GHCR with a classic PAT, use your GitHub username."), - secret: z - .string() - .describe( - "Registry password or token. For GHCR, a classic PAT with read:packages is the documented path. Visible to the MCP client on input.", - ), - }), - }, - async ({ hostname, username, secret }) => { - const res = await apiPost("/api/server/registry-credentials", { hostname, username, secret }); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const row = (await res.json()) as RegistryCredentialMetadata; - return { - content: [ - { - type: "text", - text: `Added credential for ${row.hostname} (id=${row.id}, kind=${row.secret.kind}).`, - }, - ], - structuredContent: row as unknown as Record, - }; - }, -); - -server.registerTool( - "moor_registry_credential_update", - { - title: "Update Registry Credential", - description: - "Rotate username and/or secret on an existing credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break the pull path. To change hostnames, delete and re-create. Requires at least one of username or secret. Note: the secret value passes through the MCP client on input - same security model as moor_env_set.", - inputSchema: z.object({ - id: z.number().int().positive().describe("Credential id to update"), - username: z.string().optional().describe("New username (optional)"), - secret: z - .string() - .optional() - .describe("New secret (optional). Visible to the MCP client on input."), - }), - }, - async ({ id, username, secret }) => { - if (username === undefined && secret === undefined) { - throw new Error("must provide at least one of username or secret to update"); - } - const patch: Record = {}; - if (username !== undefined) patch.username = username; - if (secret !== undefined) patch.secret = secret; - const res = await apiPut(`/api/server/registry-credentials/${id}`, patch); - if (res.status === 404) throw new Error(`credential id=${id} not found`); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const row = (await res.json()) as RegistryCredentialMetadata; - const rotated: string[] = []; - if (username !== undefined) rotated.push("username"); - if (secret !== undefined) rotated.push("secret"); - return { - content: [ - { - type: "text", - text: `Updated credential id=${id} (${row.hostname}): rotated ${rotated.join(" + ")}. kind=${row.secret.kind}.`, - }, - ], - structuredContent: row as unknown as Record, - }; - }, -); - -server.registerTool( - "moor_registry_credential_delete", - { - title: "Delete Registry Credential", - description: - "Delete a stored registry credential. Requires confirm_hostname to match the resolved row's hostname exactly - guards against deleting the wrong row from a stale id. After deletion, pulls for that registry fall back to anonymous. Irreversible.", - inputSchema: z.object({ - id: z.number().int().positive().describe("Credential id to delete"), - confirm_hostname: z - .string() - .describe( - "Must equal the credential row's hostname exactly. Resolved via moor_registry_credential_get and compared before deletion.", - ), - }), - }, - async ({ id, confirm_hostname }) => { - const getRes = await apiGet(`/api/server/registry-credentials/${id}`); - if (getRes.status === 404) throw new Error(`credential id=${id} not found`); - if (!getRes.ok) - throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`); - const row = (await getRes.json()) as RegistryCredentialMetadata; - if (confirm_hostname !== row.hostname) { - throw new Error( - `confirm_hostname "${confirm_hostname}" does not match resolved hostname "${row.hostname}". Refusing to delete.`, - ); - } - const delRes = await apiDelete(`/api/server/registry-credentials/${id}`); - if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`); - return { - content: [{ type: "text", text: `Deleted credential for ${row.hostname} (id=${id}).` }], - structuredContent: { deleted: { id, hostname: row.hostname } }, - }; - }, -); - -// --- Source credentials (HTTPS PATs for private Git repos) --- -// -// v1 ships HTTPS PATs only. Read paths return metadata + secret.kind; -// the raw token only crosses MCP on `add` and `update`. Delete uses -// confirm_label since (hostname, label) is the disambiguation key -// (multiple github.com rows coexist by design). The check tool runs a -// real `git ls-remote` inside moor to validate access before any -// deploy commits state. - -type SourceCredentialMetadata = { - id: number; - hostname: string; - label: string; - username: string; - secret: { - configured: true; - kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown"; - }; - state: "active" | "failed"; - expires_at: string | null; - last_checked_at: string | null; - last_check_status: string | null; - created_at: string; - updated_at: string; -}; - -function renderSourceCredentialLine(c: SourceCredentialMetadata): string { - const checked = c.last_check_status ? ` last_check=${c.last_check_status}` : ""; - return `id=${c.id} ${c.hostname} label=${c.label} user=${c.username} kind=${c.secret.kind} state=${c.state}${checked}`; -} - -server.registerTool( - "moor_source_credentials_list", - { - title: "List Source Credentials", - description: - "List all stored Git source credentials (HTTPS PATs). Returns metadata only - the raw secret value is never returned by any read path. Multiple credentials can share a hostname (e.g. two github.com rows for different orgs); use label to disambiguate.", - }, - async () => { - const res = await apiGet("/api/server/source-credentials"); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const data = (await res.json()) as { rows: SourceCredentialMetadata[] }; - const text = - data.rows.length === 0 - ? "No source credentials configured. Public repos work anonymously." - : data.rows.map(renderSourceCredentialLine).join("\n"); - return { - content: [{ type: "text", text }], - structuredContent: { rows: data.rows }, - }; - }, -); - -server.registerTool( - "moor_source_credential_get", - { - title: "Get Source Credential", - description: - "Get a single source credential by id. Returns metadata only. Use this before moor_source_credential_delete to confirm the label you intend to delete.", - inputSchema: z.object({ - id: z.number().int().positive().describe("Credential id (from moor_source_credentials_list)"), - }), - }, - async ({ id }) => { - const res = await apiGet(`/api/server/source-credentials/${id}`); - if (res.status === 404) throw new Error(`source credential id=${id} not found`); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const row = (await res.json()) as SourceCredentialMetadata; - return { - content: [{ type: "text", text: renderSourceCredentialLine(row) }], - structuredContent: row as unknown as Record, - }; - }, -); - -server.registerTool( - "moor_source_credential_add", - { - title: "Add Source Credential", - description: - "Store a Git source credential (HTTPS PAT) for a private repo host. v1 supports HTTPS PATs only; SSH deploy keys may be added in a future version. Hostname must be the bare host as parsed from a Git URL (github.com, gitlab.com, etc.) - no scheme, no path. Multiple credentials can share a hostname; the (hostname, label) pair is unique. For GitHub: use a fine-grained PAT with `Contents: read` (username `x-access-token`), or a classic PAT with `repo` scope. Note: the secret value passes through the MCP client and tool-call transport on input, same security model as moor_env_set.", - inputSchema: z.object({ - hostname: z - .string() - .describe("Bare Git host: github.com, gitlab.com, etc. No scheme, no path."), - label: z - .string() - .describe( - "Operator-supplied label for disambiguation when multiple credentials share a host (e.g. 'personal', 'work-org', 'acme-clients'). Trimmed at storage.", - ), - username: z - .string() - .describe( - "Git username. For GitHub fine-grained PATs, use 'x-access-token'. For classic PATs, your GitHub username works too.", - ), - secret: z - .string() - .describe( - "Git token (PAT). Visible to the MCP client on input; rotate via moor_source_credential_update if exposed.", - ), - expires_at: z - .string() - .nullable() - .optional() - .describe( - "Operator-supplied expiry timestamp (PAT expiry from GitHub). Optional; helps rotation reminders.", - ), - }), - }, - async ({ hostname, label, username, secret, expires_at }) => { - const body: Record = { hostname, label, username, secret }; - if (expires_at !== undefined) body.expires_at = expires_at; - const res = await apiPost("/api/server/source-credentials", body); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const row = (await res.json()) as SourceCredentialMetadata; - return { - content: [ - { - type: "text", - text: `Added source credential for ${row.hostname} label="${row.label}" (id=${row.id}, kind=${row.secret.kind}).`, - }, - ], - structuredContent: row as unknown as Record, - }; - }, -); - -server.registerTool( - "moor_source_credential_update", - { - title: "Update Source Credential", - description: - "Rotate username, secret, label, or expires_at on an existing source credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break in-flight builds. To change hostname, delete and recreate. Requires at least one of username, secret, label, or expires_at.", - inputSchema: z.object({ - id: z.number().int().positive().describe("Credential id to update"), - username: z.string().optional().describe("New username (optional)"), - secret: z - .string() - .optional() - .describe("New secret (optional). Visible to the MCP client on input."), - label: z.string().optional().describe("New label (optional). Trimmed at storage."), - expires_at: z.string().nullable().optional().describe("New expiry; null to clear"), - }), - }, - async ({ id, username, secret, label, expires_at }) => { - if ( - username === undefined && - secret === undefined && - label === undefined && - expires_at === undefined - ) { - throw new Error("must provide at least one of username, secret, label, or expires_at"); - } - const patch: Record = {}; - if (username !== undefined) patch.username = username; - if (secret !== undefined) patch.secret = secret; - if (label !== undefined) patch.label = label; - if (expires_at !== undefined) patch.expires_at = expires_at; - const res = await apiPut(`/api/server/source-credentials/${id}`, patch); - if (res.status === 404) throw new Error(`source credential id=${id} not found`); - if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`); - const row = (await res.json()) as SourceCredentialMetadata; - const fields: string[] = []; - if (username !== undefined) fields.push("username"); - if (secret !== undefined) fields.push("secret"); - if (label !== undefined) fields.push("label"); - if (expires_at !== undefined) fields.push("expires_at"); - return { - content: [ - { - type: "text", - text: `Updated source credential id=${id} (${row.hostname}, ${row.label}): rotated ${fields.join(" + ")}. kind=${row.secret.kind}.`, - }, - ], - structuredContent: row as unknown as Record, - }; - }, -); - -server.registerTool( - "moor_source_credential_delete", - { - title: "Delete Source Credential", - description: - "Delete a stored source credential. Requires confirm_label to match the resolved row's label exactly - protects against deleting the wrong credential on a host that has several (e.g. two github.com rows). Refused with credential_in_use if any project still references this credential. Irreversible.", - inputSchema: z.object({ - id: z.number().int().positive().describe("Credential id to delete"), - confirm_label: z - .string() - .describe( - "Must equal the credential row's label exactly. Resolved via moor_source_credential_get and compared before deletion.", - ), - }), - }, - async ({ id, confirm_label }) => { - const getRes = await apiGet(`/api/server/source-credentials/${id}`); - if (getRes.status === 404) throw new Error(`source credential id=${id} not found`); - if (!getRes.ok) - throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`); - const row = (await getRes.json()) as SourceCredentialMetadata; - if (confirm_label !== row.label) { - throw new Error( - `confirm_label "${confirm_label}" does not match resolved label "${row.label}". Refusing to delete.`, - ); - } - const delRes = await apiDelete( - `/api/server/source-credentials/${id}?confirm_label=${encodeURIComponent(row.label)}`, - ); - if (delRes.status === 409) { - const body = (await delRes.json()) as { projects: string[] }; - throw new Error( - `credential_in_use: ${body.projects.length} project(s) still reference id=${id}: ${body.projects.join(", ")}`, - ); - } - if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`); - return { - content: [ - { - type: "text", - text: `Deleted source credential ${row.hostname}/${row.label} (id=${id}).`, - }, - ], - structuredContent: { deleted: { id, hostname: row.hostname, label: row.label } }, - }; - }, -); - -server.registerTool( - "moor_source_credential_check", - { - title: "Check Source Credential Access", - description: - "Run a real `git ls-remote` against the repo URL to verify access. Without source_credential_id, probes anonymously first; if private and exactly one credential matches the host, auto-selects it. With source_credential_id, tests that exact credential. With branch, tests the specific branch (branch_not_found is distinct from auth failure). Discovers default_branch via HEAD symref when no branch is provided. Side effect: updates last_checked_at and last_check_status on the credential row; flips state to failed on a credentialed rejection.", - inputSchema: z.object({ - github_url: z - .string() - .describe( - "HTTPS Git repo URL: https://host/owner/repo (optional .git). No query, no fragment, no embedded credentials.", - ), - branch: z - .string() - .optional() - .describe("Specific branch to verify. Omit to discover the default branch."), - source_credential_id: z - .number() - .int() - .positive() - .optional() - .describe("Pin a specific credential. Required when multiple credentials match the host."), - }), - }, - async ({ github_url, branch, source_credential_id }) => { - const body: Record = { github_url }; - if (branch !== undefined) body.branch = branch; - if (source_credential_id !== undefined) body.source_credential_id = source_credential_id; - const res = await apiPost("/api/server/source-credentials/check", body); - const json = (await res.json()) as Record; - if (res.ok) { - const def = json.default_branch ? ` default_branch=${json.default_branch}` : ""; - const head = json.head_sha ? ` head_sha=${String(json.head_sha).slice(0, 12)}` : ""; - const auto = json.auto_selected_credential_id - ? ` auto_selected=${json.auto_selected_credential_id}` - : ""; - return { - content: [{ type: "text", text: `reachable${def}${head}${auto}` }], - structuredContent: json, - }; - } - // Non-OK: surface the structured failure shape directly. The agent - // can branch on `code` to decide what to do next (ask for a PAT, - // pick from candidates, etc.). - return { - content: [ - { - type: "text", - text: `check failed: code=${json.code}${json.reason ? ` reason=${json.reason}` : ""}`, - }, - ], - structuredContent: json, - isError: true, - }; - }, -); - -function formatMs(ms: number): string { - if (ms < 1000) return `${ms}ms`; - const s = Math.floor(ms / 1000); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rs = s % 60; - if (m < 60) return `${m}m${rs}s`; - const h = Math.floor(m / 60); - const rm = m % 60; - return `${h}h${rm}m${rs}s`; -} +registerProjectTools(server, client); +registerRunTools(server, client); +registerExecTools(server, client); +registerEnvTools(server, client); +registerCredentialTools(server, client); +registerServerTools(server, client); +registerUpdateTools(server, client); +registerCleanupTools(server, client); // --- Start --- diff --git a/packages/mcp/src/tools/cleanup.ts b/packages/mcp/src/tools/cleanup.ts new file mode 100644 index 0000000..700ad9e --- /dev/null +++ b/packages/mcp/src/tools/cleanup.ts @@ -0,0 +1,119 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { formatBytes } from "../format"; +import type { ToolContext } from "./context"; +export function registerCleanupTools(server: McpServer, client: ToolContext): void { + const { apiResponse, readErrorMessage } = client; + + server.registerTool( + "moor_cleanup_plan", + { + title: "Cleanup Plan (dry-run)", + description: + "Dry-run: list Docker resources that are safe to delete on this host. v1 covers build cache (host-wide prune) and dangling images (per-ID). Returns candidates with reclaimable bytes. Pass the same candidate list to moor_cleanup_execute to actually delete. No state is kept between plan and execute — execute re-validates eligibility against current Docker state.", + inputSchema: z.object({ + scope: z + .array(z.enum(["build_cache", "dangling_image"])) + .optional() + .describe("Subset of categories to plan. Defaults to all v1 categories."), + }), + }, + async ({ scope }) => { + const res = await apiResponse.post("/api/server/cleanup/plan", { scope }); + if (!res.ok) throw new Error(`plan failed: ${res.status} ${await readErrorMessage(res)}`); + const data = (await res.json()) as { + candidates: Array< + | { category: "build_cache"; reclaimable_bytes: number; label: string } + | { + category: "dangling_image"; + id: string; + reclaimable_bytes: number; + repo_tags: string[]; + label: string; + } + >; + total_reclaimable_bytes: number; + }; + if (data.candidates.length === 0) { + return { content: [{ type: "text", text: "Nothing to clean up." }] }; + } + const lines = [ + `${data.candidates.length} candidate(s), total reclaimable: ${formatBytes(data.total_reclaimable_bytes)}.`, + "Pass the candidates_json block below back to moor_cleanup_execute to delete.", + "", + ]; + for (const c of data.candidates) { + if (c.category === "build_cache") { + lines.push( + `build_cache [${c.label}] — ${formatBytes(c.reclaimable_bytes)} reclaimable (host-wide prune)`, + ); + } else { + const tags = c.repo_tags.length > 0 ? ` tags=${c.repo_tags.join(",")}` : ""; + lines.push( + `dangling_image [${c.label}] id=${c.id} ${formatBytes(c.reclaimable_bytes)}${tags}`, + ); + } + } + // Emit candidates_json so the agent doesn't have to reconstruct identifiers + // from the prose lines above. The execute side ignores extra fields, so + // passing the whole candidate objects (label, reclaimable_bytes, etc.) is + // safe — server re-validates eligibility and computes actual freed bytes. + lines.push("", "candidates_json:", JSON.stringify(data.candidates, null, 2)); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_cleanup_execute", + { + title: "Cleanup Execute", + description: + "Delete the candidates returned by moor_cleanup_plan. Server uses only the identifying fields (category + id where applicable) and re-validates eligibility against current Docker state immediately before each delete — Docker state can change between plan and execute. Reclaimable byte estimates from plan are ignored; the server reports the actual freed bytes. Every execute writes an audit row.", + inputSchema: z.object({ + candidates: z + .array( + z.union([ + z.object({ category: z.literal("build_cache") }).passthrough(), + z + .object({ category: z.literal("dangling_image"), id: z.string().min(1) }) + .passthrough(), + ]), + ) + .min(1) + .describe("Candidates from moor_cleanup_plan. Extra fields are ignored server-side."), + }), + }, + async ({ candidates }) => { + const res = await apiResponse.post("/api/server/cleanup/execute", { candidates }); + if (!res.ok) throw new Error(`execute failed: ${res.status} ${await readErrorMessage(res)}`); + const data = (await res.json()) as { + audit_id: number; + total_reclaimed_bytes: number; + results: Array< + | { category: "build_cache"; reclaimed_bytes: number; error: string | null } + | { + category: "dangling_image"; + id: string; + reclaimed_bytes: number; + error: string | null; + } + >; + }; + const lines = [ + `audit_id=${data.audit_id} total_reclaimed=${formatBytes(data.total_reclaimed_bytes)}`, + "", + ]; + for (const r of data.results) { + const status = r.error ? `ERROR: ${r.error}` : "ok"; + if (r.category === "build_cache") { + lines.push(`build_cache: reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`); + } else { + lines.push( + `dangling_image id=${r.id} reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`, + ); + } + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); +} diff --git a/packages/mcp/src/tools/context.ts b/packages/mcp/src/tools/context.ts new file mode 100644 index 0000000..1b7c3f3 --- /dev/null +++ b/packages/mcp/src/tools/context.ts @@ -0,0 +1,21 @@ +import type { Project } from "../../../contract/src/index"; + +export type ApiResponseClient = { + get(path: string): Promise; + post(path: string, body?: unknown): Promise; + put(path: string, body: unknown): Promise; + delete(path: string): Promise; +}; + +export type SseReadResult = { + logs: string; + error?: string; + structuredError?: { code: string; message: string }; +}; + +export type ToolContext = { + apiResponse: ApiResponseClient; + resolveProject(name: string): Promise; + readErrorMessage(res: Response): Promise; + readSSE(res: Response): Promise; +}; diff --git a/packages/mcp/src/tools/credentials.ts b/packages/mcp/src/tools/credentials.ts new file mode 100644 index 0000000..4c2147d --- /dev/null +++ b/packages/mcp/src/tools/credentials.ts @@ -0,0 +1,506 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { ToolContext } from "./context"; +export function registerCredentialTools(server: McpServer, client: ToolContext): void { + const { apiResponse, readErrorMessage } = client; + + server.registerTool( + "moor_dns_check", + { + title: "Check Domain DNS", + description: + "Resolves a domain's A record and reports whether it matches the server's public IP. Useful before pointing a project's domain at the server.", + inputSchema: z.object({ + domain: z.string().min(1).describe("Domain to check, e.g. app.example.com"), + }), + }, + async ({ domain }) => { + const res = await apiResponse.post("/api/dns-check", { domain }); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const data = (await res.json()) as { + resolves: boolean; + ip: string | null; + serverIp: string | null; + }; + const lines = [ + `Domain: ${domain}`, + `Resolves: ${data.resolves ? "yes" : "no"}`, + `Resolved IP: ${data.ip ?? "(none)"}`, + `Server IP: ${data.serverIp ?? "(unknown)"}`, + ]; + if (data.ip && data.serverIp) { + lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + type RegistryCredentialMetadata = { + id: number; + hostname: string; + username: string; + secret: { + configured: true; + kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown"; + }; + created_at: string; + updated_at: string; + }; + + function renderCredentialLine(c: RegistryCredentialMetadata): string { + return `id=${c.id} ${c.hostname} user=${c.username} kind=${c.secret.kind} updated=${c.updated_at}`; + } + + server.registerTool( + "moor_registry_credentials_list", + { + title: "List Registry Credentials", + description: + "List all stored Docker registry credentials. Returns metadata only - the raw secret value is never returned by any read path. Each row carries `secret: { configured: true, kind }` where kind is derived from known token prefixes (github_classic_pat, github_fine_grained_pat) or 'unknown'.", + }, + async () => { + const res = await apiResponse.get("/api/server/registry-credentials"); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const data = (await res.json()) as { rows: RegistryCredentialMetadata[] }; + const text = + data.rows.length === 0 + ? "No registry credentials configured. The pull path falls back to anonymous for every registry." + : data.rows.map(renderCredentialLine).join("\n"); + return { + content: [{ type: "text", text }], + structuredContent: { rows: data.rows }, + }; + }, + ); + + server.registerTool( + "moor_registry_credential_get", + { + title: "Get Registry Credential", + description: + "Get a single stored registry credential by id. Returns metadata only - the raw secret is never returned. Use this before moor_registry_credential_delete to confirm the hostname you intend to delete.", + inputSchema: z.object({ + id: z + .number() + .int() + .positive() + .describe("Credential id (from moor_registry_credentials_list)"), + }), + }, + async ({ id }) => { + const res = await apiResponse.get(`/api/server/registry-credentials/${id}`); + if (res.status === 404) throw new Error(`credential id=${id} not found`); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const row = (await res.json()) as RegistryCredentialMetadata; + return { + content: [{ type: "text", text: renderCredentialLine(row) }], + structuredContent: row as unknown as Record, + }; + }, + ); + + server.registerTool( + "moor_registry_credential_add", + { + title: "Add Registry Credential", + description: + "Store a credential for a Docker registry. The pull path will attach X-Registry-Auth to /images/create whenever an image ref matches this hostname. Hostname must be the bare host as it appears in the image ref (e.g. ghcr.io, docker.io, localhost:5000) - no scheme, no path. Note: the secret value passes through the MCP client and tool-call transport, same security model as moor_env_set; rotate via moor_registry_credential_update if it has been exposed.", + inputSchema: z.object({ + hostname: z + .string() + .describe( + "Bare registry host as parsed from an image ref. Examples: ghcr.io, docker.io, localhost:5000, registry.example.com:5000. No scheme, no path.", + ), + username: z + .string() + .describe("Registry username. For GHCR with a classic PAT, use your GitHub username."), + secret: z + .string() + .describe( + "Registry password or token. For GHCR, a classic PAT with read:packages is the documented path. Visible to the MCP client on input.", + ), + }), + }, + async ({ hostname, username, secret }) => { + const res = await apiResponse.post("/api/server/registry-credentials", { + hostname, + username, + secret, + }); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const row = (await res.json()) as RegistryCredentialMetadata; + return { + content: [ + { + type: "text", + text: `Added credential for ${row.hostname} (id=${row.id}, kind=${row.secret.kind}).`, + }, + ], + structuredContent: row as unknown as Record, + }; + }, + ); + + server.registerTool( + "moor_registry_credential_update", + { + title: "Update Registry Credential", + description: + "Rotate username and/or secret on an existing credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break the pull path. To change hostnames, delete and re-create. Requires at least one of username or secret. Note: the secret value passes through the MCP client on input - same security model as moor_env_set.", + inputSchema: z.object({ + id: z.number().int().positive().describe("Credential id to update"), + username: z.string().optional().describe("New username (optional)"), + secret: z + .string() + .optional() + .describe("New secret (optional). Visible to the MCP client on input."), + }), + }, + async ({ id, username, secret }) => { + if (username === undefined && secret === undefined) { + throw new Error("must provide at least one of username or secret to update"); + } + const patch: Record = {}; + if (username !== undefined) patch.username = username; + if (secret !== undefined) patch.secret = secret; + const res = await apiResponse.put(`/api/server/registry-credentials/${id}`, patch); + if (res.status === 404) throw new Error(`credential id=${id} not found`); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const row = (await res.json()) as RegistryCredentialMetadata; + const rotated: string[] = []; + if (username !== undefined) rotated.push("username"); + if (secret !== undefined) rotated.push("secret"); + return { + content: [ + { + type: "text", + text: `Updated credential id=${id} (${row.hostname}): rotated ${rotated.join(" + ")}. kind=${row.secret.kind}.`, + }, + ], + structuredContent: row as unknown as Record, + }; + }, + ); + + server.registerTool( + "moor_registry_credential_delete", + { + title: "Delete Registry Credential", + description: + "Delete a stored registry credential. Requires confirm_hostname to match the resolved row's hostname exactly - guards against deleting the wrong row from a stale id. After deletion, pulls for that registry fall back to anonymous. Irreversible.", + inputSchema: z.object({ + id: z.number().int().positive().describe("Credential id to delete"), + confirm_hostname: z + .string() + .describe( + "Must equal the credential row's hostname exactly. Resolved via moor_registry_credential_get and compared before deletion.", + ), + }), + }, + async ({ id, confirm_hostname }) => { + const getRes = await apiResponse.get(`/api/server/registry-credentials/${id}`); + if (getRes.status === 404) throw new Error(`credential id=${id} not found`); + if (!getRes.ok) + throw new Error( + `Failed to fetch credential: ${getRes.status} ${await readErrorMessage(getRes)}`, + ); + const row = (await getRes.json()) as RegistryCredentialMetadata; + if (confirm_hostname !== row.hostname) { + throw new Error( + `confirm_hostname "${confirm_hostname}" does not match resolved hostname "${row.hostname}". Refusing to delete.`, + ); + } + const delRes = await apiResponse.delete(`/api/server/registry-credentials/${id}`); + if (!delRes.ok) + throw new Error(`Failed to delete: ${delRes.status} ${await readErrorMessage(delRes)}`); + return { + content: [{ type: "text", text: `Deleted credential for ${row.hostname} (id=${id}).` }], + structuredContent: { deleted: { id, hostname: row.hostname } }, + }; + }, + ); + + type SourceCredentialMetadata = { + id: number; + hostname: string; + label: string; + username: string; + secret: { + configured: true; + kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown"; + }; + state: "active" | "failed"; + expires_at: string | null; + last_checked_at: string | null; + last_check_status: string | null; + created_at: string; + updated_at: string; + }; + + function renderSourceCredentialLine(c: SourceCredentialMetadata): string { + const checked = c.last_check_status ? ` last_check=${c.last_check_status}` : ""; + return `id=${c.id} ${c.hostname} label=${c.label} user=${c.username} kind=${c.secret.kind} state=${c.state}${checked}`; + } + + server.registerTool( + "moor_source_credentials_list", + { + title: "List Source Credentials", + description: + "List all stored Git source credentials (HTTPS PATs). Returns metadata only - the raw secret value is never returned by any read path. Multiple credentials can share a hostname (e.g. two github.com rows for different orgs); use label to disambiguate.", + }, + async () => { + const res = await apiResponse.get("/api/server/source-credentials"); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const data = (await res.json()) as { rows: SourceCredentialMetadata[] }; + const text = + data.rows.length === 0 + ? "No source credentials configured. Public repos work anonymously." + : data.rows.map(renderSourceCredentialLine).join("\n"); + return { + content: [{ type: "text", text }], + structuredContent: { rows: data.rows }, + }; + }, + ); + + server.registerTool( + "moor_source_credential_get", + { + title: "Get Source Credential", + description: + "Get a single source credential by id. Returns metadata only. Use this before moor_source_credential_delete to confirm the label you intend to delete.", + inputSchema: z.object({ + id: z + .number() + .int() + .positive() + .describe("Credential id (from moor_source_credentials_list)"), + }), + }, + async ({ id }) => { + const res = await apiResponse.get(`/api/server/source-credentials/${id}`); + if (res.status === 404) throw new Error(`source credential id=${id} not found`); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const row = (await res.json()) as SourceCredentialMetadata; + return { + content: [{ type: "text", text: renderSourceCredentialLine(row) }], + structuredContent: row as unknown as Record, + }; + }, + ); + + server.registerTool( + "moor_source_credential_add", + { + title: "Add Source Credential", + description: + "Store a Git source credential (HTTPS PAT) for a private repo host. v1 supports HTTPS PATs only; SSH deploy keys may be added in a future version. Hostname must be the bare host as parsed from a Git URL (github.com, gitlab.com, etc.) - no scheme, no path. Multiple credentials can share a hostname; the (hostname, label) pair is unique. For GitHub: use a fine-grained PAT with `Contents: read` (username `x-access-token`), or a classic PAT with `repo` scope. Note: the secret value passes through the MCP client and tool-call transport on input, same security model as moor_env_set.", + inputSchema: z.object({ + hostname: z + .string() + .describe("Bare Git host: github.com, gitlab.com, etc. No scheme, no path."), + label: z + .string() + .describe( + "Operator-supplied label for disambiguation when multiple credentials share a host (e.g. 'personal', 'work-org', 'acme-clients'). Trimmed at storage.", + ), + username: z + .string() + .describe( + "Git username. For GitHub fine-grained PATs, use 'x-access-token'. For classic PATs, your GitHub username works too.", + ), + secret: z + .string() + .describe( + "Git token (PAT). Visible to the MCP client on input; rotate via moor_source_credential_update if exposed.", + ), + expires_at: z + .string() + .nullable() + .optional() + .describe( + "Operator-supplied expiry timestamp (PAT expiry from GitHub). Optional; helps rotation reminders.", + ), + }), + }, + async ({ hostname, label, username, secret, expires_at }) => { + const body: Record = { hostname, label, username, secret }; + if (expires_at !== undefined) body.expires_at = expires_at; + const res = await apiResponse.post("/api/server/source-credentials", body); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const row = (await res.json()) as SourceCredentialMetadata; + return { + content: [ + { + type: "text", + text: `Added source credential for ${row.hostname} label="${row.label}" (id=${row.id}, kind=${row.secret.kind}).`, + }, + ], + structuredContent: row as unknown as Record, + }; + }, + ); + + server.registerTool( + "moor_source_credential_update", + { + title: "Update Source Credential", + description: + "Rotate username, secret, label, or expires_at on an existing source credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break in-flight builds. To change hostname, delete and recreate. Requires at least one of username, secret, label, or expires_at.", + inputSchema: z.object({ + id: z.number().int().positive().describe("Credential id to update"), + username: z.string().optional().describe("New username (optional)"), + secret: z + .string() + .optional() + .describe("New secret (optional). Visible to the MCP client on input."), + label: z.string().optional().describe("New label (optional). Trimmed at storage."), + expires_at: z.string().nullable().optional().describe("New expiry; null to clear"), + }), + }, + async ({ id, username, secret, label, expires_at }) => { + if ( + username === undefined && + secret === undefined && + label === undefined && + expires_at === undefined + ) { + throw new Error("must provide at least one of username, secret, label, or expires_at"); + } + const patch: Record = {}; + if (username !== undefined) patch.username = username; + if (secret !== undefined) patch.secret = secret; + if (label !== undefined) patch.label = label; + if (expires_at !== undefined) patch.expires_at = expires_at; + const res = await apiResponse.put(`/api/server/source-credentials/${id}`, patch); + if (res.status === 404) throw new Error(`source credential id=${id} not found`); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const row = (await res.json()) as SourceCredentialMetadata; + const fields: string[] = []; + if (username !== undefined) fields.push("username"); + if (secret !== undefined) fields.push("secret"); + if (label !== undefined) fields.push("label"); + if (expires_at !== undefined) fields.push("expires_at"); + return { + content: [ + { + type: "text", + text: `Updated source credential id=${id} (${row.hostname}, ${row.label}): rotated ${fields.join(" + ")}. kind=${row.secret.kind}.`, + }, + ], + structuredContent: row as unknown as Record, + }; + }, + ); + + server.registerTool( + "moor_source_credential_delete", + { + title: "Delete Source Credential", + description: + "Delete a stored source credential. Requires confirm_label to match the resolved row's label exactly - protects against deleting the wrong credential on a host that has several (e.g. two github.com rows). Refused with credential_in_use if any project still references this credential. Irreversible.", + inputSchema: z.object({ + id: z.number().int().positive().describe("Credential id to delete"), + confirm_label: z + .string() + .describe( + "Must equal the credential row's label exactly. Resolved via moor_source_credential_get and compared before deletion.", + ), + }), + }, + async ({ id, confirm_label }) => { + const getRes = await apiResponse.get(`/api/server/source-credentials/${id}`); + if (getRes.status === 404) throw new Error(`source credential id=${id} not found`); + if (!getRes.ok) + throw new Error( + `Failed to fetch credential: ${getRes.status} ${await readErrorMessage(getRes)}`, + ); + const row = (await getRes.json()) as SourceCredentialMetadata; + if (confirm_label !== row.label) { + throw new Error( + `confirm_label "${confirm_label}" does not match resolved label "${row.label}". Refusing to delete.`, + ); + } + const delRes = await apiResponse.delete( + `/api/server/source-credentials/${id}?confirm_label=${encodeURIComponent(row.label)}`, + ); + if (delRes.status === 409) { + const body = (await delRes.json()) as { projects: string[] }; + throw new Error( + `credential_in_use: ${body.projects.length} project(s) still reference id=${id}: ${body.projects.join(", ")}`, + ); + } + if (!delRes.ok) + throw new Error(`Failed to delete: ${delRes.status} ${await readErrorMessage(delRes)}`); + return { + content: [ + { + type: "text", + text: `Deleted source credential ${row.hostname}/${row.label} (id=${id}).`, + }, + ], + structuredContent: { deleted: { id, hostname: row.hostname, label: row.label } }, + }; + }, + ); + + server.registerTool( + "moor_source_credential_check", + { + title: "Check Source Credential Access", + description: + "Run a real `git ls-remote` against the repo URL to verify access. Without source_credential_id, probes anonymously first; if private and exactly one credential matches the host, auto-selects it. With source_credential_id, tests that exact credential. With branch, tests the specific branch (branch_not_found is distinct from auth failure). Discovers default_branch via HEAD symref when no branch is provided. Side effect: updates last_checked_at and last_check_status on the credential row; flips state to failed on a credentialed rejection.", + inputSchema: z.object({ + github_url: z + .string() + .describe( + "HTTPS Git repo URL: https://host/owner/repo (optional .git). No query, no fragment, no embedded credentials.", + ), + branch: z + .string() + .optional() + .describe("Specific branch to verify. Omit to discover the default branch."), + source_credential_id: z + .number() + .int() + .positive() + .optional() + .describe( + "Pin a specific credential. Required when multiple credentials match the host.", + ), + }), + }, + async ({ github_url, branch, source_credential_id }) => { + const body: Record = { github_url }; + if (branch !== undefined) body.branch = branch; + if (source_credential_id !== undefined) body.source_credential_id = source_credential_id; + const res = await apiResponse.post("/api/server/source-credentials/check", body); + const json = (await res.json()) as Record; + if (res.ok) { + const def = json.default_branch ? ` default_branch=${json.default_branch}` : ""; + const head = json.head_sha ? ` head_sha=${String(json.head_sha).slice(0, 12)}` : ""; + const auto = json.auto_selected_credential_id + ? ` auto_selected=${json.auto_selected_credential_id}` + : ""; + return { + content: [{ type: "text", text: `reachable${def}${head}${auto}` }], + structuredContent: json, + }; + } + // Non-OK: surface the structured failure shape directly. The agent + // can branch on `code` to decide what to do next (ask for a PAT, + // pick from candidates, etc.). + return { + content: [ + { + type: "text", + text: `check failed: code=${json.code}${json.reason ? ` reason=${json.reason}` : ""}`, + }, + ], + structuredContent: json, + isError: true, + }; + }, + ); +} diff --git a/packages/mcp/src/tools/env.ts b/packages/mcp/src/tools/env.ts new file mode 100644 index 0000000..aa4db57 --- /dev/null +++ b/packages/mcp/src/tools/env.ts @@ -0,0 +1,444 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { isJsonObject, validateCronSchedule } from "../../../contract/src/index"; +import type { ToolContext } from "./context"; +export function registerEnvTools(server: McpServer, client: ToolContext): void { + const { apiResponse, resolveProject, readErrorMessage } = client; + + server.registerTool( + "moor_env_list", + { + title: "List Environment Variables", + description: "List all environment variables set for a project.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + }), + }, + async ({ project }) => { + const p = await resolveProject(project); + const res = await apiResponse.get(`/api/projects/${p.id}/envs`); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const vars = (await res.json()) as { key: string; value: string }[]; + if (vars.length === 0) + return { content: [{ type: "text", text: "No environment variables set." }] }; + const text = vars.map((v) => `${v.key}=${v.value}`).join("\n"); + return { content: [{ type: "text", text }] }; + }, + ); + + server.registerTool( + "moor_env_set", + { + title: "Set Environment Variables", + description: + "Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + vars: z + .record(z.string(), z.string()) + .describe('Key-value pairs to set, e.g. { "DATABASE_URL": "postgres://..." }'), + }), + }, + async ({ project, vars }) => { + const p = await resolveProject(project); + + // Fetch existing and merge + const existingRes = await apiResponse.get(`/api/projects/${p.id}/envs`); + if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`); + const existing = (await existingRes.json()) as { key: string; value: string }[]; + const merged = new Map(existing.map((v) => [v.key, v.value])); + for (const [key, value] of Object.entries(vars)) { + merged.set(key, value); + } + const allVars = Array.from(merged, ([key, value]) => ({ key, value })); + + const setRes = await apiResponse.put(`/api/projects/${p.id}/envs`, allVars); + if (!setRes.ok) throw new Error(`Failed to set envs: ${await readErrorMessage(setRes)}`); + + const keys = Object.keys(vars).join(", "); + let text = `Set ${keys} on ${p.name}.`; + + // Restart if running + if (p.status === "running") { + await apiResponse.post(`/api/projects/${p.id}/stop`); + const startRes = await apiResponse.post(`/api/projects/${p.id}/start`); + if (!startRes.ok) + throw new Error(`Set vars but failed to restart: ${await readErrorMessage(startRes)}`); + text += " Container restarted."; + } + + return { content: [{ type: "text", text }] }; + }, + ); + + server.registerTool( + "moor_cron_create", + { + title: "Create Cron", + description: + "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + name: z.string().min(1).describe("Human-readable name for the cron"), + schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'), + command: z.string().min(1).describe("Shell command to run inside the project's container"), + }), + }, + async ({ project, name, schedule, command }) => { + const err = validateCronSchedule(schedule); + if (err) throw new Error(`Invalid schedule: ${err}`); + const p = await resolveProject(project); + const res = await apiResponse.post(`/api/projects/${p.id}/crons`, { + name, + schedule, + command, + }); + if (!res.ok) throw new Error(`Failed to create cron: ${await readErrorMessage(res)}`); + const cron = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] }; + }, + ); + + server.registerTool( + "moor_cron_update", + { + title: "Update Cron", + description: "Updates a cron's fields by id. Schedule is validated if provided.", + inputSchema: z.object({ + cron_id: z.number().int().positive().describe("Cron ID"), + name: z.string().min(1).optional(), + schedule: z.string().optional(), + command: z.string().min(1).optional(), + enabled: z.boolean().optional().describe("Enable or disable the cron"), + }), + }, + async ({ cron_id, name, schedule, command, enabled }) => { + if (schedule !== undefined) { + const err = validateCronSchedule(schedule); + if (err) throw new Error(`Invalid schedule: ${err}`); + } + const body: Record = {}; + if (name !== undefined) body.name = name; + if (schedule !== undefined) body.schedule = schedule; + if (command !== undefined) body.command = command; + if (enabled !== undefined) body.enabled = enabled ? 1 : 0; + if (Object.keys(body).length === 0) { + throw new Error("Provide at least one field to update"); + } + const res = await apiResponse.put(`/api/crons/${cron_id}`, body); + if (!res.ok) throw new Error(`Failed to update cron: ${await readErrorMessage(res)}`); + const cron = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] }; + }, + ); + + server.registerTool( + "moor_cron_delete", + { + title: "Delete Cron", + description: "Deletes a cron by id.", + inputSchema: z.object({ + cron_id: z.number().int().positive().describe("Cron ID"), + }), + }, + async ({ cron_id }) => { + const res = await apiResponse.delete(`/api/crons/${cron_id}`); + if (!res.ok) throw new Error(`Failed to delete cron: ${await readErrorMessage(res)}`); + // API returns 204 whether or not the row existed; phrase the response so it + // doesn't claim a row was removed when it might already have been gone. + return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] }; + }, + ); + + server.registerTool( + "moor_cron_run", + { + title: "Run Cron Now", + description: + "Triggers a cron to run immediately. Requires the project's container to be running.", + inputSchema: z.object({ + cron_id: z.number().int().positive().describe("Cron ID"), + }), + }, + async ({ cron_id }) => { + const res = await apiResponse.post(`/api/crons/${cron_id}/run`); + if (!res.ok) { + const text = await readErrorMessage(res); + let message = text; + try { + const parsed = JSON.parse(text) as unknown; + if (isJsonObject(parsed) && typeof parsed.error === "string") message = parsed.error; + } catch { + // Not JSON; use raw text + } + throw new Error(message); + } + return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] }; + }, + ); + + server.registerTool( + "moor_env_delete", + { + title: "Delete Environment Variables", + description: + "Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + keys: z.array(z.string().min(1)).min(1).describe("Env var keys to remove"), + }), + }, + async ({ project, keys }) => { + const p = await resolveProject(project); + + const existingRes = await apiResponse.get(`/api/projects/${p.id}/envs`); + if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`); + const existing = (await existingRes.json()) as { key: string; value: string }[]; + const existingKeys = new Set(existing.map((v) => v.key)); + + const toDelete = keys.filter((k) => existingKeys.has(k)); + const missing = keys.filter((k) => !existingKeys.has(k)); + + if (toDelete.length === 0) { + const existingList = [...existingKeys].sort().join(", ") || "(none)"; + return { + content: [ + { + type: "text", + text: `No matching keys on ${p.name}. Existing keys: ${existingList}`, + }, + ], + }; + } + + for (const key of toDelete) { + const res = await apiResponse.delete( + `/api/projects/${p.id}/envs/${encodeURIComponent(key)}`, + ); + if (!res.ok) throw new Error(`Failed to delete ${key}: ${await readErrorMessage(res)}`); + } + + let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`; + if (missing.length > 0) text += ` (Not present: ${missing.join(", ")}.)`; + + if (p.status === "running") { + await apiResponse.post(`/api/projects/${p.id}/stop`); + const startRes = await apiResponse.post(`/api/projects/${p.id}/start`); + if (!startRes.ok) { + throw new Error( + `Deleted vars but failed to restart: ${await readErrorMessage(startRes)}`, + ); + } + text += " Container restarted."; + } + + return { content: [{ type: "text", text }] }; + }, + ); + + server.registerTool( + "moor_volume_list", + { + title: "List Project Volumes", + description: + "List the named Docker volumes attached to a project. Each entry includes the logical name (per-project handle), the in-container target path, and the actual Docker volume name (for `docker volume ls` / `docker volume inspect` outside moor).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + }), + }, + async ({ project }) => { + const p = await resolveProject(project); + const res = await apiResponse.get(`/api/projects/${p.id}/volumes`); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const rows = (await res.json()) as Array<{ + id: number; + name: string; + target: string; + docker_name: string; + }>; + if (rows.length === 0) { + return { content: [{ type: "text", text: `No volumes attached to ${p.name}.` }] }; + } + const lines = rows.map( + (v) => `id=${v.id} name=${v.name} target=${v.target} docker_name=${v.docker_name}`, + ); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_volume_add", + { + title: "Add Project Volume", + description: + "Attach a named Docker volume to a project. The volume is created lazily by Docker on first container start; moor stores the mount config (logical name, in-container target, and the generated docker_name like moor--). Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run) — already-running containers keep their existing mounts.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + name: z + .string() + .min(1) + .describe("Logical volume name (unique per project; alphanumeric/_/-)"), + target: z + .string() + .min(1) + .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"), + }), + }, + async ({ project, name, target }) => { + const p = await resolveProject(project); + const res = await apiResponse.post(`/api/projects/${p.id}/volumes`, { name, target }); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const created = (await res.json()) as { + id: number; + name: string; + target: string; + docker_name: string; + }; + return { + content: [ + { + type: "text", + text: `Attached volume to ${p.name}: id=${created.id}, name=${created.name}, target=${created.target}, docker_name=${created.docker_name}. Mount applies on next container recreate.`, + }, + ], + }; + }, + ); + + server.registerTool( + "moor_volume_remove", + { + title: "Remove Project Volume Mount", + description: + "Detach a named volume from a project's mount config. The underlying Docker volume (and its data) is intentionally preserved — to actually delete the data, use moor_project_delete with purge_volumes:true, or run `docker volume rm ` manually. Takes effect on next container recreate.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + volume_id: z.number().int().positive().describe("Volume ID from moor_volume_list"), + }), + }, + async ({ project, volume_id }) => { + const p = await resolveProject(project); + const res = await apiResponse.delete(`/api/projects/${p.id}/volumes/${volume_id}`); + if (res.status === 404) throw new Error(`Volume ${volume_id} not found on project ${p.name}`); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const body = (await res.json()) as { docker_name: string; message: string }; + return { content: [{ type: "text", text: body.message }] }; + }, + ); + + server.registerTool( + "moor_file_set", + { + title: "Set Project File", + description: + "Declare a file to inject into a project's container. moor writes it via a tar archive PUT right before the container starts, on every recreate, honoring the octal mode (e.g. 0600 for a TLS key). Identified by path — setting the same path again updates its content/mode rather than duplicating. Provide exactly one of content (inline) or env_ref (the name of a project env var to source content from at create time, so a secret stays in the env store instead of plaintext here). Takes effect on next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + path: z + .string() + .min(1) + .describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"), + content: z + .string() + .optional() + .describe("Inline file contents. Provide exactly one of content or env_ref."), + env_ref: z + .string() + .optional() + .describe( + "Name of a project env var to source the contents from at create time. Keeps secrets (keys, certs) in the env store instead of plaintext here. Provide exactly one of content or env_ref.", + ), + mode: z + .string() + .optional() + .describe( + "Octal permission string applied in the tar header, e.g. '0600'. Default '0644'.", + ), + }), + }, + async ({ project, path, content, env_ref, mode }) => { + const p = await resolveProject(project); + const body: Record = { path }; + if (content !== undefined) body.content = content; + if (env_ref !== undefined) body.env_ref = env_ref; + if (mode !== undefined) body.mode = mode; + const res = await apiResponse.post(`/api/projects/${p.id}/files`, body); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const saved = (await res.json()) as { + id: number; + path: string; + mode: string; + source: string; + env_ref: string | null; + }; + const verb = res.status === 201 ? "Added" : "Updated"; + return { + content: [ + { + type: "text", + text: `${verb} file on ${p.name}: id=${saved.id}, path=${saved.path}, mode=${saved.mode}, source=${saved.source}${saved.env_ref ? ` (env_ref=${saved.env_ref})` : ""}. Written into the container on next recreate.`, + }, + ], + }; + }, + ); + + server.registerTool( + "moor_file_list", + { + title: "List Project Files", + description: + "List the declarative files configured for a project. Each entry shows the in-container path, octal mode, and how content is sourced (inline or env). Raw inline content is never returned (it may be large, and env-sourced content lives in the env store).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + }), + }, + async ({ project }) => { + const p = await resolveProject(project); + const res = await apiResponse.get(`/api/projects/${p.id}/files`); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const rows = (await res.json()) as Array<{ + id: number; + path: string; + mode: string; + source: string; + env_ref: string | null; + }>; + if (rows.length === 0) { + return { content: [{ type: "text", text: `No files configured for ${p.name}.` }] }; + } + const lines = rows.map( + (f) => + `id=${f.id} path=${f.path} mode=${f.mode} source=${f.source}${f.env_ref ? ` env_ref=${f.env_ref}` : ""}`, + ); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_file_remove", + { + title: "Remove Project File", + description: + "Remove a declared file from a project's injection set. The file stops being written on future container recreates; a copy already present in a running container is not deleted until the next recreate. Takes effect on next container recreate.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + file_id: z.number().int().positive().describe("File ID from moor_file_list"), + }), + }, + async ({ project, file_id }) => { + const p = await resolveProject(project); + const res = await apiResponse.delete(`/api/projects/${p.id}/files/${file_id}`); + if (res.status === 404) throw new Error(`File ${file_id} not found on project ${p.name}`); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + return { + content: [ + { + type: "text", + text: `Removed file ${file_id} from ${p.name}. Applies on next container recreate.`, + }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/exec.ts b/packages/mcp/src/tools/exec.ts new file mode 100644 index 0000000..7f0b2bd --- /dev/null +++ b/packages/mcp/src/tools/exec.ts @@ -0,0 +1,190 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { appendStream, formatMs } from "../format"; +import type { ToolContext } from "./context"; +export function registerExecTools(server: McpServer, client: ToolContext): void { + const { apiResponse, resolveProject, readErrorMessage } = client; + + server.registerTool( + "moor_exec", + { + title: "Execute Command", + description: + "Run a shell command inside a project's running container. Bounded by a per-call timeout (default 10 min, max 1 h). For jobs that may exceed an hour, use moor_exec_async.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + command: z.string().describe("Shell command to execute"), + timeout_ms: z + .number() + .int() + .min(1000) + .max(3_600_000) + .optional() + .describe( + "Max time in milliseconds before the exec is aborted. Default 600000 (10 min). Max 3600000 (1 h).", + ), + }), + }, + async ({ project, command, timeout_ms }) => { + const p = await resolveProject(project); + const body: Record = { command }; + if (timeout_ms !== undefined) body.timeout_ms = timeout_ms; + const res = await apiResponse.post(`/api/projects/${p.id}/exec`, body); + // The API returns 504 with a structured timeout body when the exec hit + // timeout_ms. Surface the kill outcome in the tool error so the agent can + // tell "the process was actually stopped" from "we just stopped waiting." + if (res.status === 504) { + const t = (await res.json()) as { + timeout_ms: number; + killed: boolean; + killed_pid: string | null; + live_remaining: number; + message: string; + }; + let detail: string; + if (t.killed) { + detail = `Process tree terminated (container pid ${t.killed_pid}).`; + } else if (t.killed_pid !== null) { + detail = `Kill attempted on container pid ${t.killed_pid} but ${t.live_remaining} descendant process(es) still running inside the container.`; + } else { + detail = + "Process kill could not locate the running process — it may still be running inside the container."; + } + throw new Error(`Exec timed out after ${t.timeout_ms}ms. ${detail}`); + } + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const result = (await res.json()) as { + exitCode: number; + stdout: string; + stderr: string; + }; + let text = ""; + if (result.stdout) text += result.stdout; + if (result.stderr) text += `\n[stderr] ${result.stderr}`; + text += `\n[exit code: ${result.exitCode}]`; + return { content: [{ type: "text", text }] }; + }, + ); + + server.registerTool( + "moor_exec_async", + { + title: "Start Async Exec", + description: + "Run a long-lived command inside a project's container, returning immediately with a run_id. Use moor_exec_status to poll for output and exit code; moor_exec_stop to terminate. Bounded by an optional timeout_ms (default 86400000 = 24h; min 60000 = 1 min; max 86400000). The recorded output is tail-truncated to the last 64 KiB per stream; stdout_total_bytes and stderr_total_bytes report the full pre-truncation byte count.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + command: z.string().min(1).describe("Shell command to execute"), + timeout_ms: z + .number() + .int() + .min(60_000) + .max(86_400_000) + .optional() + .describe( + "Safety timeout in milliseconds. When exceeded, the process tree is terminated and the run is marked timed_out. Default 86400000 (24h). Min 60000. Max 86400000.", + ), + }), + }, + async ({ project, command, timeout_ms }) => { + const p = await resolveProject(project); + const body: Record = { command }; + if (timeout_ms !== undefined) body.timeout_ms = timeout_ms; + const res = await apiResponse.post(`/api/projects/${p.id}/exec/async`, body); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const data = (await res.json()) as { run_id: number }; + return { + content: [ + { + type: "text", + text: `Started async exec on ${p.name}. run_id=${data.run_id}. Use moor_exec_status to poll; moor_exec_stop to terminate.`, + }, + ], + }; + }, + ); + + server.registerTool( + "moor_exec_status", + { + title: "Get Async Exec Status", + description: + "Return the current state of an async exec run: state, exit code (when finished), running tail of stdout/stderr (default 8 KiB each inline; the API stores up to 64 KiB), total bytes seen, duration, and any error message. State is one of: running, exited, stopped, timed_out, error. Pass tail_bytes to control how many bytes of each stream are returned inline (0 to 65536; default 8192). The API's 64 KiB-per-stream storage cap is unchanged — tail_bytes only controls what the MCP tool returns to keep responses under typical agent token limits.", + inputSchema: z.object({ + run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"), + tail_bytes: z + .number() + .int() + .min(0) + .max(65_536) + .optional() + .describe( + "Max bytes of each stream (stdout, stderr) returned inline. Default 8192. Max 65536 (the API storage cap). Set to 0 for metadata-only.", + ), + }), + }, + async ({ run_id, tail_bytes }) => { + const cap = tail_bytes ?? 8192; + const res = await apiResponse.get(`/api/exec/${run_id}`); + if (res.status === 404) throw new Error(`run_id ${run_id} not found`); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const data = (await res.json()) as { + id: number; + state: string; + exit_code: number | null; + stdout: string; + stderr: string; + stdout_total_bytes: number; + stderr_total_bytes: number; + duration_ms: number; + command: string; + killed_pid: string | null; + error_message: string | null; + started_at: string; + finished_at: string | null; + }; + const lines: string[] = []; + lines.push( + `run_id=${data.id} state=${data.state} duration=${formatMs(data.duration_ms)}` + + (data.exit_code !== null ? ` exit_code=${data.exit_code}` : ""), + ); + lines.push(`command: ${data.command}`); + if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`); + if (data.error_message) lines.push(`error: ${data.error_message}`); + appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap); + appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_exec_stop", + { + title: "Stop Async Exec", + description: + "Terminate a running async exec by run_id. Walks the descendant process tree inside the container and sends SIGTERM then SIGKILL. Always transitions the run to a terminal state: state=stopped on clean termination (all descendants gone), state=error if any descendant survived OR if the kill handle was lost (moor restart, missing pidfile). Stop is NOT retry-safe — the kill script removes the pidfile after every attempt, and reparented survivors are unreachable from the original PID.", + inputSchema: z.object({ + run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"), + }), + }, + async ({ run_id }) => { + const res = await apiResponse.post(`/api/exec/${run_id}/stop`); + if (res.status === 404) throw new Error(`run_id ${run_id} not found`); + const data = (await res.json()) as { + ok: boolean; + state: string; + killed_pid: string | null; + live_remaining: number; + message: string; + }; + return { + content: [ + { + type: "text", + text: `run_id=${run_id} state=${data.state} ${data.message}`, + }, + ], + }; + }, + ); +} diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts new file mode 100644 index 0000000..a5f4939 --- /dev/null +++ b/packages/mcp/src/tools/projects.ts @@ -0,0 +1,728 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { + isJsonObject, + type Project, + validateGithubRepoUrl, + validateGithubUrl, +} from "../../../contract/src/index"; +import type { ToolContext } from "./context"; +export function registerProjectTools(server: McpServer, client: ToolContext): void { + const { apiResponse, resolveProject, readErrorMessage, readSSE } = client; + + server.registerTool( + "moor_status", + { + title: "List Projects", + description: + "List all projects managed by Moor. `status` is moor's recorded state (only changes on explicit start/stop/build/cancel). `live_status` is Docker's view at last successful inspect; differences (e.g. recorded='running' live='error') mean moor missed an external change like a host docker stop, crash, or OOM kill. `live_error` non-null means the most recent inspect failed and the live_* values are the last successful snapshot, not necessarily current.", + }, + async () => { + const res = await apiResponse.get("/api/projects"); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const projects = (await res.json()) as Project[]; + const summary = projects.map((p) => ({ + name: p.name, + status: p.status, + live_status: p.live_status ?? null, + live_exit_code: p.live_exit_code ?? null, + live_checked_at: p.live_checked_at ?? null, + live_error: p.live_error ?? null, + source: p.docker_image || p.github_url || null, + domain: p.domain, + })); + return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; + }, + ); + + server.registerTool( + "moor_project_get", + { + title: "Get Project", + description: + "Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + }), + }, + async ({ project }) => { + const p = await resolveProject(project); + return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] }; + }, + ); + + server.registerTool( + "moor_project_create", + { + title: "Create Project", + description: + "Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild to bring it up, or use moor_deploy to create and start in one step.", + inputSchema: z.object({ + name: z + .string() + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, + "name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -", + ) + .describe("Project name (used as the container name suffix: moor-)"), + github_url: z + .string() + .optional() + .describe("github.com URL; mutually exclusive with docker_image"), + docker_image: z + .string() + .optional() + .describe( + "Docker image reference (e.g. nginx:latest); mutually exclusive with github_url", + ), + branch: z + .string() + .optional() + .describe("Git branch (default: main, for github_url projects)"), + dockerfile: z + .string() + .optional() + .describe("Dockerfile path within the repo (default: Dockerfile)"), + domain: z + .string() + .optional() + .describe("Public domain to route to this container via Caddy"), + domain_port: z + .number() + .int() + .positive() + .optional() + .describe("Container port Caddy should forward to (required if domain is set)"), + restart_policy: z + .enum(["no", "on-failure", "always", "unless-stopped"]) + .optional() + .describe("Docker restart policy (default: unless-stopped)"), + memory_limit_mb: z + .number() + .int() + .min(6) + .optional() + .describe( + "Max RAM in MB (also caps swap to the same value so the container can't burn through host swap). Min 6 (Docker's floor), max host total memory. Omit for unbounded. Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run).", + ), + cpus: z + .number() + .min(0.001) + .optional() + .describe( + "Max CPU cores. Fractional values OK (e.g. 0.5 = half a core). Min 0.001 (anything smaller rounds to Docker NanoCpus=0, which means unlimited — use omit for that). Max host core count. Takes effect on container recreate.", + ), + volumes: z + .array( + z.object({ + name: z.string().min(1).describe("Logical volume name (unique per project)"), + target: z + .string() + .min(1) + .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"), + }), + ) + .optional() + .describe( + "Named Docker volumes to attach. Each entry creates a per-project volume (stored as moor--) and mounts it at the given target on next container recreate. Data survives container/project rebuilds unless explicitly purged via project delete with purge_volumes=true.", + ), + source_credential_id: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "For github_url projects: pin the source credential row (from moor_source_credential_add) the build path should use. Build synthesizes the credentialed clone URL in memory; the secret is never stored on the project. Ignored when docker_image is set; save-time validation is structural only (id exists).", + ), + command: z + .array(z.string()) + .nullable() + .optional() + .describe( + 'Override the image\'s default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Lets a stock image run a custom command with no throwaway Dockerfile. Omit to keep the image default; pass [] or null to clear a previously-set override. Applies on container recreate.', + ), + entrypoint: z + .array(z.string()) + .nullable() + .optional() + .describe( + "Override the image's ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.", + ), + }), + }, + async (input) => { + const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0); + if (sources !== 1) { + throw new Error("Provide exactly one of github_url or docker_image"); + } + if (input.github_url) validateGithubUrl(input.github_url); + + const { volumes, ...createBody } = input; + const res = await apiResponse.post("/api/projects", createBody); + if (!res.ok) throw new Error(`Failed to create project: ${await readErrorMessage(res)}`); + const project = (await res.json()) as { id: number }; + + // Volumes are a separate endpoint so the API stays single-concern. Loop + // through them; if any one fails, report what landed and what didn't. + const volumeFailures: Array<{ name: string; error: string }> = []; + const volumeCreated: string[] = []; + if (volumes && volumes.length > 0) { + for (const v of volumes) { + const vRes = await apiResponse.post(`/api/projects/${project.id}/volumes`, v); + if (vRes.ok) volumeCreated.push(v.name); + else volumeFailures.push({ name: v.name, error: await readErrorMessage(vRes) }); + } + } + + const lines = [JSON.stringify(project, null, 2)]; + if (volumeCreated.length > 0) { + lines.push(`\nCreated volumes: ${volumeCreated.join(", ")}`); + } + if (volumeFailures.length > 0) { + lines.push( + `\nVolume failures (project was still created): ${volumeFailures + .map((f) => `${f.name}: ${f.error}`) + .join("; ")}`, + ); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_project_update", + { + title: "Update Project", + description: + "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately. Resource-limit changes (memory_limit_mb, cpus) take effect on the next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run) — an already-running container keeps its existing limits.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID to update"), + name: z + .string() + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, + "name must start alphanumeric; allowed: a-z A-Z 0-9 _ -", + ) + .optional(), + github_url: z.string().optional(), + docker_image: z.string().optional(), + branch: z.string().optional(), + dockerfile: z.string().optional(), + domain: z.string().optional(), + domain_port: z.number().int().positive().optional(), + restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(), + memory_limit_mb: z + .number() + .int() + .min(6) + .nullable() + .optional() + .describe( + "Max RAM in MB. Pass null to clear (return to unbounded). Min 6, max host total memory. Takes effect on container recreate.", + ), + cpus: z + .number() + .min(0.001) + .nullable() + .optional() + .describe( + "Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate.", + ), + source_credential_id: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "Pin (or unlink, by passing null) the source credential the build path should use for this github_url project. Switching to docker_image force-clears the id regardless of input. Save-time validation is structural only; host-mismatch / not-active is enforced at build time.", + ), + command: z + .array(z.string()) + .nullable() + .optional() + .describe( + 'Override the image\'s default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Pass [] or null to clear the override and return to the image default. Takes effect on container recreate.', + ), + entrypoint: z + .array(z.string()) + .nullable() + .optional() + .describe( + "Override the image's ENTRYPOINT as an argv array. Pass [] or null to clear. Takes effect on container recreate.", + ), + }), + }, + async (input) => { + const { project, ...updates } = input; + if (Object.keys(updates).length === 0) { + throw new Error("Provide at least one field to update"); + } + if (updates.github_url && updates.docker_image) { + throw new Error("Cannot set both github_url and docker_image in the same update"); + } + if (updates.github_url) validateGithubUrl(updates.github_url); + + const p = await resolveProject(project); + const res = await apiResponse.put(`/api/projects/${p.id}`, updates); + if (!res.ok) throw new Error(`Failed to update project: ${await readErrorMessage(res)}`); + const updated = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] }; + }, + ); + + server.registerTool( + "moor_project_delete", + { + title: "Delete Project", + description: + "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible. Named Docker volumes are preserved by default (data survives so a recreated project can remount them); pass purge_volumes: true to also delete the underlying Docker volumes — that deletion is also irreversible.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID to delete"), + confirm_name: z + .string() + .describe( + "Must equal the resolved project's name. Guards against deleting the wrong project.", + ), + purge_volumes: z + .boolean() + .optional() + .default(false) + .describe( + "Also delete the underlying Docker volumes (their data). Default false: project gone, volumes (and their data) preserved. The volume metadata is cleaned up either way; this flag only controls whether the data goes too.", + ), + }), + }, + async ({ project, confirm_name, purge_volumes }) => { + const p = await resolveProject(project); + if (confirm_name !== p.name) { + throw new Error( + `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`, + ); + } + const qs = purge_volumes ? "?purge_volumes=true" : ""; + const res = await apiResponse.delete(`/api/projects/${p.id}${qs}`); + if (!res.ok) { + const text = await readErrorMessage(res); + let message = text; + try { + const parsed = JSON.parse(text) as unknown; + if (isJsonObject(parsed) && typeof parsed.message === "string") { + message = parsed.message; + } + } catch { + // not json + } + throw new Error(`Failed to delete project: ${message}`); + } + // 204 No Content (no purge or no volumes) vs 200 JSON (purge with results) + if (res.status === 204) { + return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] }; + } + const body = (await res.json()) as { volumes_purged?: number }; + return { + content: [ + { + type: "text", + text: `Deleted project ${p.name} (id=${p.id}). Purged ${body.volumes_purged ?? 0} Docker volume(s).`, + }, + ], + }; + }, + ); + + server.registerTool( + "moor_deploy", + { + title: "Deploy Project", + description: + "Create-or-update a project end to end: metadata, env vars (merged into existing), and an optional build/run. Default fails if the project already exists; pass update_existing: true to upsert. When run: true (default), waits for the full Docker build/pull and start, which can take minutes for large images. Errors are tagged by the failing step ([create], [update], [set_env], or [run]) and do not roll back earlier steps.", + inputSchema: z.object({ + name: z + .string() + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, + "name must start alphanumeric; allowed chars: a-z A-Z 0-9 _ -", + ) + .describe("Project name (also the container suffix: moor-)"), + github_url: z + .string() + .optional() + .describe( + "GitHub repo URL: host must be github.com or www.github.com, path must be /owner/repo (optional .git). Mutually exclusive with docker_image.", + ), + docker_image: z + .string() + .optional() + .describe( + "Docker image reference (e.g. nginx:latest). Mutually exclusive with github_url.", + ), + branch: z.string().optional().describe("Git branch (API default: main)"), + dockerfile: z + .string() + .optional() + .describe("Dockerfile path in the repo (API default: Dockerfile)"), + domain: z.string().optional().describe("Public domain to route via Caddy"), + domain_port: z + .number() + .int() + .positive() + .optional() + .describe("Container port Caddy should forward to"), + restart_policy: z + .enum(["no", "on-failure", "always", "unless-stopped"]) + .optional() + .describe("Docker restart policy (API default: unless-stopped)"), + memory_limit_mb: z + .number() + .int() + .min(6) + .nullable() + .optional() + .describe( + "Max RAM in MB (also caps swap to the same value). Min 6, max host total memory. Pass null on update to clear. Limits apply on container recreate, which deploy always does when run: true.", + ), + cpus: z + .number() + .min(0.001) + .nullable() + .optional() + .describe( + "Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear.", + ), + volumes: z + .array( + z.object({ + name: z.string().min(1), + target: z.string().min(1), + }), + ) + .optional() + .describe( + "Named Docker volumes to attach. Each entry becomes a per-project volume (stored as moor--) and mounts at the given target on container recreate. On update_existing, additions only — no removals. Data survives container/project rebuilds unless explicitly purged via moor_project_delete with confirm_name (purge_volumes is a separate flag).", + ), + env: z + .record(z.string(), z.string()) + .optional() + .describe( + "Env vars to MERGE into existing project envs. Omit to leave envs untouched. Pass {} for an explicit no-op. Use moor_env_delete to remove keys.", + ), + source_credential_id: z + .number() + .int() + .positive() + .nullable() + .optional() + .describe( + "For github_url projects: pin the source credential row (created via moor_source_credential_add). Build path synthesizes the credentialed clone URL in memory; secret never gets stored on the project row. Pass null to detach without switching source type. Ignored when docker_image is set. Save-time validation is structural only (id exists); host-mismatch / not-active is enforced at build time so configuration can survive transient credential outages.", + ), + command: z + .array(z.string()) + .nullable() + .optional() + .describe( + 'Override the image default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Lets a stock image (e.g. cloudflare/cloudflared) run a custom command with no throwaway Dockerfile. Omit to keep the image default; pass [] or null to clear. Applies on the recreate the run step performs.', + ), + entrypoint: z + .array(z.string()) + .nullable() + .optional() + .describe( + "Override the image ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.", + ), + files: z + .array( + z.object({ + path: z + .string() + .min(1) + .describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"), + content: z + .string() + .optional() + .describe("Inline file contents. Provide exactly one of content or env_ref."), + env_ref: z + .string() + .optional() + .describe( + "Name of a project env var to source the contents from at create time, so a secret (TLS key, token) lives in the env store rather than in plaintext here. Provide exactly one of content or env_ref.", + ), + mode: z + .string() + .optional() + .describe( + "Octal permission string for the tar header, e.g. '0600'. Default '0644'.", + ), + }), + ) + .optional() + .describe( + "Declarative files to inject into the container before it starts, written on every recreate (additions/updates only — moor_deploy never removes files; use moor_file_remove). Each file's path identifies it; re-deploying the same path updates its content. Honors the octal mode in the tar header (e.g. 0600 for a key).", + ), + run: z + .boolean() + .optional() + .default(true) + .describe( + "Build/pull and start after create/update. Default true. Setting false leaves the container untouched; if envs changed while the container is running, the change will not apply until the next run/restart.", + ), + update_existing: z + .boolean() + .optional() + .default(false) + .describe("Allow updating a project that already exists. Default false (create-only)."), + }), + }, + async (input) => { + // Up-front validation: do strict checks before any side effects. + if (input.github_url) validateGithubRepoUrl(input.github_url); + if (input.github_url && input.docker_image) { + throw new Error("Cannot set both github_url and docker_image"); + } + + // #79: drain-mode preflight. moor_deploy is a composition: by the + // time the run step (Step 3) hits the drain 503 from /api/projects/ + // :id/run, the create/update/volume/env side effects have already + // landed. Check drain server-side BEFORE any writes so a drained + // deploy fails cleanly without leaving partial state. + // + // Skipped when run: false because the no-run mode is metadata-only — + // no container work, so drain doesn't apply. + if (input.run !== false) { + const drainRes = await apiResponse.get("/api/server/drain"); + if (drainRes.ok) { + const { state } = (await drainRes.json()) as { + state: { enabled: boolean; reason: string | null; expires_at: string | null }; + }; + if (state.enabled) { + throw new Error( + `[drain] moor is draining (reason: ${state.reason ?? "(none)"}; expires_at: ${state.expires_at}). Refusing deploy before any project create/update side effects. Use moor_drain_disable to re-enable, or retry after expiry. Pass run: false if you only need metadata changes.`, + ); + } + } + // If the drain endpoint is unreachable (older moor or transient + // failure), don't block the deploy — the per-route gate inside + // /api/projects/:id/run will still catch it before container work + // starts. Preflight is an optimization, not the guarantee. + } + + // Resolve existence and check domain conflicts from a single project list. + const listRes = await apiResponse.get("/api/projects"); + if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`); + const projects = (await listRes.json()) as Project[]; + const existing = projects.find((p) => p.name === input.name); + + if (existing && !input.update_existing) { + throw new Error( + `Project "${input.name}" already exists. Pass update_existing: true to update it.`, + ); + } + + if (!existing) { + const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0); + if (sources !== 1) { + throw new Error("Provide exactly one of github_url or docker_image"); + } + } + + // Normalize once for both the conflict check and the write. The API trims but + // does not lowercase, so " Example.com " vs an existing "example.com" would + // slip past the raw-string pre-check and only surface as a Caddy collision. + const normalizedDomain = + input.domain === undefined ? undefined : input.domain.trim().toLowerCase() || null; + + if (normalizedDomain) { + const conflict = projects.find( + (p) => + p.domain && p.domain.trim().toLowerCase() === normalizedDomain && p.id !== existing?.id, + ); + if (conflict) { + throw new Error( + `Domain "${normalizedDomain}" is already used by project "${conflict.name}" (id=${conflict.id}). Refusing before Caddy reload.`, + ); + } + } + + // Step 1: create or update project metadata. + let projectId: number; + let projectName: string; + if (!existing) { + const createBody: Record = { + name: input.name, + github_url: input.github_url, + docker_image: input.docker_image, + branch: input.branch, + dockerfile: input.dockerfile, + domain: normalizedDomain, + domain_port: input.domain_port, + restart_policy: input.restart_policy, + memory_limit_mb: input.memory_limit_mb, + cpus: input.cpus, + source_credential_id: input.source_credential_id, + command: input.command, + entrypoint: input.entrypoint, + }; + const res = await apiResponse.post("/api/projects", createBody); + if (!res.ok) throw new Error(`[create] ${await readErrorMessage(res)}`); + const created = (await res.json()) as Project; + projectId = created.id; + projectName = created.name; + } else { + // Update only fields explicitly provided. `name` is the lookup key here, + // not a rename target; use moor_project_update for renames. + const updateBody: Record = {}; + if (input.github_url !== undefined) updateBody.github_url = input.github_url; + if (input.docker_image !== undefined) updateBody.docker_image = input.docker_image; + if (input.branch !== undefined) updateBody.branch = input.branch; + if (input.dockerfile !== undefined) updateBody.dockerfile = input.dockerfile; + if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain; + if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port; + if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy; + if (input.memory_limit_mb !== undefined) updateBody.memory_limit_mb = input.memory_limit_mb; + if (input.cpus !== undefined) updateBody.cpus = input.cpus; + if (input.source_credential_id !== undefined) + updateBody.source_credential_id = input.source_credential_id; + if (input.command !== undefined) updateBody.command = input.command; + if (input.entrypoint !== undefined) updateBody.entrypoint = input.entrypoint; + + if (Object.keys(updateBody).length > 0) { + const res = await apiResponse.put(`/api/projects/${existing.id}`, updateBody); + if (!res.ok) throw new Error(`[update] ${await readErrorMessage(res)}`); + } + projectId = existing.id; + projectName = existing.name; + } + + // Step 1.5: add named volumes (additions only — moor_deploy never removes + // volumes, even on update_existing). Mounts apply on next container + // recreate, which the run step below triggers by default. + if (input.volumes && input.volumes.length > 0) { + // Cache the existing list once so we can resolve 409s without re-fetching + // per conflict. Only fetched if a 409 actually occurs. + let existingVolumes: Array<{ name: string; target: string }> | null = null; + for (const v of input.volumes) { + const vRes = await apiResponse.post(`/api/projects/${projectId}/volumes`, v); + if (vRes.ok) continue; + const text = await readErrorMessage(vRes); + if (vRes.status !== 409) { + throw new Error(`[volumes] failed to add ${v.name}: ${text}`); + } + // 409 is tolerable ONLY if the existing volume matches the requested + // spec exactly (same name, same target). A 409 with a drifted target + // means the operator changed the desired mount and we'd silently + // ignore the change — fail loudly instead. + if (existingVolumes === null) { + const listRes = await apiResponse.get(`/api/projects/${projectId}/volumes`); + if (!listRes.ok) { + throw new Error( + `[volumes] could not resolve 409 on ${v.name}: failed to list existing volumes: ${await readErrorMessage(listRes)}`, + ); + } + existingVolumes = (await listRes.json()) as Array<{ name: string; target: string }>; + } + const match = existingVolumes.find((e) => e.name === v.name); + if (!match) { + // 409 was for some other reason (target collision under a different + // name, or cross-project docker_name collision). Operator must + // intervene. + throw new Error( + `[volumes] conflict adding ${v.name}: ${text} (no existing volume by that name; check for target collision)`, + ); + } + if (match.target !== v.target) { + throw new Error( + `[volumes] conflict adding ${v.name}: existing target "${match.target}" differs from requested "${v.target}". moor_deploy does not change mount targets; use moor_volume_remove + moor_volume_add explicitly.`, + ); + } + // Same name, same target — idempotent re-run, tolerable. + } + } + + // Step 1.6: inject declarative files (additions/updates only — deploy never + // removes files; use moor_file_remove for that). The route upserts by path, + // so re-deploying the same path updates its content. Files are written into + // the container right before start on the recreate the run step triggers. + if (input.files && input.files.length > 0) { + for (const f of input.files) { + const fRes = await apiResponse.post(`/api/projects/${projectId}/files`, f); + if (!fRes.ok) { + throw new Error(`[files] failed to set ${f.path}: ${await readErrorMessage(fRes)}`); + } + } + } + + // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op. + const envEntries = input.env ? Object.entries(input.env) : []; + const envProvided = envEntries.length > 0; + if (envProvided) { + const existingRes = await apiResponse.get(`/api/projects/${projectId}/envs`); + if (!existingRes.ok) { + throw new Error(`[set_env] Failed to read envs: ${existingRes.status}`); + } + const existingEnvs = (await existingRes.json()) as { key: string; value: string }[]; + const merged = new Map(existingEnvs.map((v) => [v.key, v.value])); + for (const [k, v] of envEntries) merged.set(k, v); + const allVars = Array.from(merged, ([key, value]) => ({ key, value })); + const putRes = await apiResponse.put(`/api/projects/${projectId}/envs`, allVars); + if (!putRes.ok) throw new Error(`[set_env] ${await readErrorMessage(putRes)}`); + } + + // Step 3: run, default true. Wait for the full SSE stream like moor_rebuild. + let runLogs = ""; + let runStructuredError: { code: string; message: string } | undefined; + if (input.run) { + const runRes = await apiResponse.post(`/api/projects/${projectId}/run`); + if (!runRes.ok) throw new Error(`[run] ${await readErrorMessage(runRes)}`); + const { logs, error, structuredError } = await readSSE(runRes); + runLogs = logs; + // #119: classified failure (today: source_credential_required) is + // returned as isError below so the agent can branch on the code + // instead of parsing a thrown message. + if (structuredError) { + runStructuredError = structuredError; + } else if (error) { + throw new Error(`[run] ${error}`); + } + } + + const lines: string[] = []; + lines.push( + existing + ? `Updated project ${projectName} (id=${projectId}).` + : `Created project ${projectName} (id=${projectId}).`, + ); + if (envProvided) { + lines.push( + `Merged ${envEntries.length} env var(s): ${envEntries.map(([k]) => k).join(", ")}.`, + ); + } + if (!input.run) { + if (envProvided && existing?.status === "running") { + lines.push( + "Note: project is running; env changes will not take effect until the next run or restart.", + ); + } + } else { + lines.push(""); + lines.push("Build/run output:"); + lines.push(runLogs || "(no output)"); + } + // #119: if the build was classified as auth-failure, return isError + // with the structured payload so the agent can call _check + add a + // credential and retry. The project row exists (create/update already + // committed) so the agent just needs to fix the credential and run + // deploy again with the pinned id. + if (runStructuredError) { + lines.push(""); + lines.push(`Failed: code=${runStructuredError.code} message=${runStructuredError.message}`); + return { + content: [{ type: "text", text: lines.join("\n") }], + structuredContent: { ...runStructuredError, project_id: projectId }, + isError: true, + }; + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); +} diff --git a/packages/mcp/src/tools/runs.ts b/packages/mcp/src/tools/runs.ts new file mode 100644 index 0000000..d625654 --- /dev/null +++ b/packages/mcp/src/tools/runs.ts @@ -0,0 +1,290 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { appendStream, deriveRunStatus, deriveRunType, formatMsShort } from "../format"; +import type { ToolContext } from "./context"; +export function registerRunTools(server: McpServer, client: ToolContext): void { + const { apiResponse, resolveProject, readErrorMessage, readSSE } = client; + + server.registerTool( + "moor_logs", + { + title: "Get Container Logs", + description: + "Get recent logs from a project's container. Annotates output with state: ok (container running), exited (container is stopped but Docker still has logs), no_container (project never started), or missing (container_id is set but Docker doesn't have it). Throws only on docker_error (Docker daemon 5xx / unreachable) so an operator can distinguish infrastructure failure from app silence — pre-#74 the tool returned empty logs for all of these.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + lines: z.number().optional().default(100).describe("Number of log lines to retrieve"), + }), + }, + async ({ project, lines }) => { + const p = await resolveProject(project); + const res = await apiResponse.get(`/api/projects/${p.id}/logs?tail=${lines}`); + // 502 = API surfaced a Docker daemon failure. Throw so the agent + // gets a tool error, not silent empty logs. + if (res.status === 502) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(`Docker error: ${data.error ?? "unknown"}`); + } + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const data = (await res.json()) as { logs: string; state?: string }; + switch (data.state) { + case "no_container": + return { + content: [{ type: "text", text: "(project hasn't been started yet — no container)" }], + }; + case "missing": + return { + content: [ + { + type: "text", + text: "(container_id was recorded but Docker doesn't have it; moor may need to recreate the project)", + }, + ], + }; + case "exited": + return { + content: [ + { + type: "text", + text: `${data.logs || "(no logs captured)"}\n\n(container is exited; logs above are from before)`, + }, + ], + }; + default: + // "ok" or undefined (older API) — render raw. + return { + content: [{ type: "text", text: data.logs || "(no logs)" }], + }; + } + }, + ); + + server.registerTool( + "moor_rebuild", + { + title: "Rebuild Project", + description: + "Rebuild a project from source (git pull + docker build) and restart the container. Returns the build output when it finishes. While a build is in flight, the most recent moor_runs entry has finished_at=null — call moor_run_get on its id to tail the live output. Use moor_rebuild for code, Dockerfile, or base-image changes. For env vars / resource limits / port / volume / restart-policy changes, or to recover a crashed container from the existing image, use moor_restart — it skips the build and is much faster.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + no_cache: z.boolean().optional().default(false).describe("Build without Docker cache"), + }), + }, + async ({ project, no_cache }) => { + const p = await resolveProject(project); + const query = no_cache ? "?nocache=true" : ""; + const res = await apiResponse.post(`/api/projects/${p.id}/run${query}`); + // /run can fail BEFORE opening the SSE stream — resolver validation, + // drain mode, invalid URL, credential_not_active. Those land as a + // plain JSON or text body that readSSE walks without matching any + // event:/data: lines, returning empty everything. Without this guard + // the tool would silently report "Rebuild complete." on a failed build. + // Mirrors the existing moor_deploy guard at the /run call site. + if (!res.ok) throw new Error(`[run] ${await readErrorMessage(res)}`); + const { logs, error, structuredError } = await readSSE(res); + // #119: a classified failure (today: source_credential_required) gets + // returned as isError with a structured payload the agent can branch + // on. Unclassified errors keep throwing so the existing UX is preserved. + if (structuredError) { + return { + content: [ + { + type: "text", + text: `rebuild failed: code=${structuredError.code} message=${structuredError.message}`, + }, + ], + structuredContent: structuredError, + isError: true, + }; + } + if (error) throw new Error(error); + return { content: [{ type: "text", text: logs || "Rebuild complete." }] }; + }, + ); + + server.registerTool( + "moor_restart", + { + title: "Restart Project", + description: + "Stop and recreate a project's container from its existing image. Does NOT pull from git or rebuild — uses the existing image_tag. Right tool for: applying changed env vars / resource limits / ports / volumes / restart policy, recovering a crashed container, or simply bouncing the process. Wrong tool for: code or Dockerfile changes (use moor_rebuild — those need a new image).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + }), + }, + async ({ project }) => { + const p = await resolveProject(project); + const stopRes = await apiResponse.post(`/api/projects/${p.id}/stop`); + if (!stopRes.ok) throw new Error(`Failed to stop: ${await readErrorMessage(stopRes)}`); + const startRes = await apiResponse.post(`/api/projects/${p.id}/start`); + if (!startRes.ok) throw new Error(`Failed to start: ${await readErrorMessage(startRes)}`); + return { content: [{ type: "text", text: `${p.name} restarted.` }] }; + }, + ); + + server.registerTool( + "moor_runs", + { + title: "List Project Run History", + description: + "Paginated list of cron runs and build runs for a project. Returns one compact line per run (id, type, status, exit code, duration, output byte counts, timestamps) — stdout/stderr bodies are NOT included to avoid blowing token budgets on large build outputs. Use moor_run_get(run_id) to fetch the stored output for a single run (cron rows store full output; build/manual rows store at most a 64 KiB tail with the original total bytes recorded separately).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + page: z + .number() + .int() + .positive() + .optional() + .default(1) + .describe("Page number (20 runs per page). Default 1."), + }), + }, + async ({ project, page }) => { + const p = await resolveProject(project); + const res = await apiResponse.get( + `/api/projects/${p.id}/runs?include_output=false&page=${page}`, + ); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const data = (await res.json()) as { + runs: Array<{ + id: number; + cron_id: number | null; + cron_name: string | null; + cron_command: string | null; + started_at: string; + finished_at: string | null; + exit_code: number | null; + duration_ms: number | null; + stdout_bytes: number; + stderr_bytes: number; + stdout_total_bytes?: number; + stderr_total_bytes?: number; + }>; + total: number; + }; + if (data.runs.length === 0) { + return { + content: [{ type: "text", text: `No runs recorded for ${p.name}.` }], + }; + } + const lines: string[] = []; + lines.push( + `${p.name}: ${data.runs.length} run(s) on page ${page}, ${data.total} total. Use moor_run_get(run_id) for stored output (build/manual rows are tail-truncated; total bytes shown below).`, + ); + for (const r of data.runs) { + const type = deriveRunType(r); + const status = deriveRunStatus(r); + const exit = r.exit_code != null ? ` exit=${r.exit_code}` : ""; + const cmd = r.cron_command ? ` cmd="${r.cron_command}"` : ""; + // #65: surface "what was emitted" (total) per byte field. For live or + // already-truncated build runs total > stored; for crons and historical + // build rows they're equal. Showing total is the operationally useful + // number — "what did Docker actually produce" — and stays accurate as a + // build streams in. Fall back to stdout_bytes if the API is old. + const outTotal = r.stdout_total_bytes ?? r.stdout_bytes; + const errTotal = r.stderr_total_bytes ?? r.stderr_bytes; + lines.push( + `id=${r.id} ${type} ${status}${exit} dur=${formatMsShort(r.duration_ms)} stdout=${outTotal}B stderr=${errTotal}B started=${r.started_at}${cmd}`, + ); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_run_get", + { + title: "Get Run Detail", + description: + "Fetch one cron or build run with its stdout and stderr. Output is tail-truncated (default 8 KiB per stream; max 65536) to keep responses under typical agent token limits. Use tail_bytes=0 for metadata-only.", + inputSchema: z.object({ + run_id: z.number().int().positive().describe("Run ID returned by moor_runs"), + tail_bytes: z + .number() + .int() + .min(0) + .max(65_536) + .optional() + .describe( + "Max bytes of each stream returned inline. Default 8192. Max 65536. Set to 0 for metadata-only.", + ), + }), + }, + async ({ run_id, tail_bytes }) => { + const cap = tail_bytes ?? 8192; + const res = await apiResponse.get(`/api/runs/${run_id}`); + if (res.status === 404) throw new Error(`run_id ${run_id} not found`); + if (!res.ok) throw new Error(`Failed: ${await readErrorMessage(res)}`); + const r = (await res.json()) as { + id: number; + cron_id: number | null; + cron_name: string | null; + cron_command: string | null; + started_at: string; + finished_at: string | null; + exit_code: number | null; + duration_ms: number | null; + stdout: string | null; + stderr: string | null; + stdout_total_bytes?: number | null; + stderr_total_bytes?: number | null; + }; + const lines: string[] = []; + const type = deriveRunType(r); + const status = deriveRunStatus(r); + const exit = r.exit_code != null ? ` exit_code=${r.exit_code}` : ""; + lines.push( + `run_id=${r.id} ${type} ${status}${exit} duration=${formatMsShort(r.duration_ms)}`, + ); + if (r.cron_command) lines.push(`cron_command: ${r.cron_command}`); + lines.push(`started_at: ${r.started_at}`); + if (r.finished_at) lines.push(`finished_at: ${r.finished_at}`); + // #65: runs.stdout/stderr for build runs is a server-side 64 KiB tail + // (TAIL_CAP_BYTES). Use stdout_total_bytes / stderr_total_bytes when the + // API provides them so appendStream can honestly report "last X of Y". + // For cron rows the stored payload IS the full output, and total == stored. + // Fall back to encoded length for older APIs that don't return the totals. + const stdoutStr = r.stdout ?? ""; + const stderrStr = r.stderr ?? ""; + const enc = new TextEncoder(); + const stdoutTotal = r.stdout_total_bytes ?? enc.encode(stdoutStr).length; + const stderrTotal = r.stderr_total_bytes ?? enc.encode(stderrStr).length; + appendStream(lines, "stdout", stdoutStr, stdoutTotal, cap); + appendStream(lines, "stderr", stderrStr, stderrTotal, cap); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_run_stop", + { + title: "Stop or Cancel a Run", + description: + "Stops an active cron run or cancels an active build/pull run (from moor_rebuild / moor_deploy). Closing the connection to the Docker build/pull endpoint aborts the daemon-side job. Cancellation is only valid during the build/pull streaming phase — once the build finishes and container start has begun, the call returns not_cancellable. Returns one of: cancelled, cancelled_cron, not_cancellable, already_finished, not_active, not_found. These are all expected outcomes, not errors — the tool throws only on unexpected server failures.", + inputSchema: z.object({ + run_id: z.number().int().positive().describe("Run ID from moor_runs"), + }), + }, + async ({ run_id }) => { + const res = await apiResponse.post(`/api/runs/${run_id}/stop`); + // The /stop route returns 200 for cancelled/cancelled_cron and 4xx + // for the rest of the known result categories (with a result field + // either way). All of those are expected outcomes — render them as + // content so the agent can react without try/catch. Only surface as + // an error if the response doesn't fit the documented shape (server + // error, parse failure, etc). + let data: { ok?: boolean; result?: string; error?: string }; + try { + data = (await res.json()) as { ok?: boolean; result?: string; error?: string }; + } catch { + throw new Error(`run_id=${run_id} server error: ${res.status} ${res.statusText}`); + } + if (typeof data.result === "string") { + return { content: [{ type: "text", text: `run_id=${run_id} ${data.result}` }] }; + } + throw new Error( + `run_id=${run_id} unexpected response: status=${res.status} body=${JSON.stringify(data)}`, + ); + }, + ); +} diff --git a/packages/mcp/src/tools/server.ts b/packages/mcp/src/tools/server.ts new file mode 100644 index 0000000..b379a90 --- /dev/null +++ b/packages/mcp/src/tools/server.ts @@ -0,0 +1,297 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { type DrainState, formatBytes, renderDrainState } from "../format"; +import type { ToolContext } from "./context"; +export function registerServerTools(server: McpServer, client: ToolContext): void { + const { apiResponse, resolveProject, readErrorMessage } = client; + + server.registerTool( + "moor_stats", + { + title: "Server Stats", + description: + "Get server resource usage: load, memory, per-filesystem disk usage (the filesystems the moor container can see, plus any operator-configured monitored host disks via MOOR_MONITORED_DISKS), Docker disk by category (images/containers/volumes/build cache) with reclaimable bytes, and container counts. Note: cpu.percent is load-derived (load avg ÷ cores), not instantaneous CPU; use the `load` field for the same signal with explicit naming.", + }, + async () => { + const res = await apiResponse.get("/api/server/stats"); + if (!res.ok) throw new Error(`Failed: ${res.status}`); + const s = (await res.json()) as { + hostname: string; + os: string; + uptime: string; + cpu: { percent: number; cores: number }; + load?: { one_min: number; cores: number; normalized_percent: number }; + memory: { total: string; used: string; percent: number }; + disk: { total: string; used: string; percent: number }; + disks?: { mount: string; total: string; used: string; percent: number; label?: string }[]; + containers: { running: number; total: number }; + docker?: { + images: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number }; + containers: { + bytes: number; + reclaimable_bytes: number; + count: number; + stopped_count: number; + }; + volumes: { + bytes: number; + reclaimable_bytes: number; + count: number; + unused_count: number; + }; + build_cache: { bytes: number; reclaimable_bytes: number; count: number }; + } | null; + }; + const lines = [ + `Host: ${s.hostname}`, + `OS: ${s.os}`, + `Uptime: ${s.uptime}`, + `CPU: ${s.cpu.percent}% (${s.cpu.cores} cores) — load-derived, not instantaneous`, + ]; + if (s.load) { + lines.push( + `Load (1m): ${s.load.one_min.toFixed(2)} on ${s.load.cores} cores (${s.load.normalized_percent}%)`, + ); + } + lines.push(`Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`); + const disks = s.disks?.length ? s.disks : [{ mount: "/", ...s.disk }]; + for (const d of disks) { + const name = d.label ? `${d.label} (${d.mount})` : `Disk ${d.mount}`; + lines.push(`${name}: ${d.used} / ${d.total} (${d.percent}%)`); + } + lines.push(`Containers: ${s.containers.running} running / ${s.containers.total} total`); + if (s.docker) { + const d = s.docker; + lines.push( + "Docker disk:", + ` Images: ${formatBytes(d.images.bytes)} (${formatBytes(d.images.reclaimable_bytes)} reclaimable, ${d.images.unused_count}/${d.images.count} unused)`, + ` Containers: ${formatBytes(d.containers.bytes)} (${formatBytes(d.containers.reclaimable_bytes)} reclaimable, ${d.containers.stopped_count}/${d.containers.count} stopped)`, + ` Volumes: ${formatBytes(d.volumes.bytes)} (${formatBytes(d.volumes.reclaimable_bytes)} reclaimable, ${d.volumes.unused_count}/${d.volumes.count} unused)`, + ` Build cache: ${formatBytes(d.build_cache.bytes)} (${formatBytes(d.build_cache.reclaimable_bytes)} reclaimable, ${d.build_cache.count} entries)`, + ); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + type DrainStateResponse = { + state: DrainState; + }; + + type DrainStatusResponse = DrainStateResponse & { + active_work: { + builds_in_flight: number; + execs_in_flight: number; + crons_in_flight: number; + terminals_open: number; + }; + }; + + server.registerTool( + "moor_drain_status", + { + title: "Drain Status", + description: + "Read-only: current drain state (enabled, reason, expires_at, clear_after_version) plus counts of active work the operator should wait on before an update. active_work uses the same counter as moor_update_status so the two never disagree.", + }, + async () => { + const res = await apiResponse.get("/api/server/drain"); + if (!res.ok) + throw new Error(`drain status failed: ${res.status} ${await readErrorMessage(res)}`); + const s = (await res.json()) as DrainStatusResponse; + const lines = renderDrainState(s.state); + lines.push( + `active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`, + ); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_drain_enable", + { + title: "Enable Drain Mode", + description: + "Refuse new builds, deploys, execs, manual cron runs, and terminal upgrades with a 503 carrying { reason, expires_at, hint }. Existing in-flight work runs to completion — drain does NOT kill anything. Scheduled cron ticks during drain write a synthetic 'skipped due to drain' run row instead of executing. Read-only routes (status, logs, runs) keep working. Default TTL is 30 minutes; set ttl_minutes to override. clear_after_version is the updater's hook — when set, the drain auto-clears on boot if the running moor version matches.", + inputSchema: z.object({ + reason: z + .string() + .optional() + .describe( + "Freeform reason shown in every refusal response (e.g. 'preparing for 0.34 upgrade').", + ), + ttl_minutes: z + .number() + .optional() + .describe( + "Auto-clear after this many minutes. Default 30. Clamped to [0.05 min, 7 days].", + ), + clear_after_version: z + .string() + .optional() + .describe( + "Optional: on next boot, if the running moor version equals this value, auto-clear the drain. Typically set by the updater path; safe for manual use too.", + ), + }), + }, + async ({ reason, ttl_minutes, clear_after_version }) => { + const res = await apiResponse.post("/api/server/drain/enable", { + reason, + ttl_minutes, + clear_after_version, + }); + if (!res.ok) + throw new Error(`drain enable failed: ${res.status} ${await readErrorMessage(res)}`); + const s = (await res.json()) as DrainStateResponse; + return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] }; + }, + ); + + server.registerTool( + "moor_drain_disable", + { + title: "Disable Drain Mode", + description: + "Explicit operator action to clear drain immediately. Does not kill or restart anything — just removes the gate so new builds/deploys/execs/cron triggers/terminal upgrades succeed again.", + }, + async () => { + const res = await apiResponse.post("/api/server/drain/disable", {}); + if (!res.ok) + throw new Error(`drain disable failed: ${res.status} ${await readErrorMessage(res)}`); + const s = (await res.json()) as DrainStateResponse; + return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] }; + }, + ); + + server.registerTool( + "moor_db_backup", + { + title: "DB Backup (snapshot)", + description: + "Take a SQLite snapshot of moor.db via VACUUM INTO. The file lands next to the main DB as moor.db.backup-. Retention is enforced after each snapshot (keeps the 7 most recent by default; older ones are pruned). After this returns, moor_update_status' db_backup.age_seconds will read close to 0. Use before a manual `docker compose pull moor && up -d` if you don't have MOOR_DB_BACKUP_INTERVAL_HOURS scheduled.", + }, + async () => { + const res = await apiResponse.post("/api/server/backup", {}); + if (!res.ok) + throw new Error(`db backup failed: ${res.status} ${await readErrorMessage(res)}`); + const r = (await res.json()) as { path: string; sizeBytes: number; durationMs: number }; + const mb = (r.sizeBytes / (1024 * 1024)).toFixed(2); + return { + content: [ + { + type: "text", + text: `Snapshot written: ${r.path}\nsize: ${r.sizeBytes}B (${mb} MB)\nduration: ${r.durationMs}ms`, + }, + ], + }; + }, + ); + + server.registerTool( + "moor_project_stats", + { + title: "Project Container Stats (live)", + description: + "Live container stats for one project: CPU percent, memory (excluding page cache, same accounting as `docker stats`), network and block I/O totals, PID count. Single Docker stats snapshot — CPU uses the cpu_stats/precpu_stats delta the daemon already includes. Stopped or never-started projects return running=false with zeroed counters (no 404).", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + }), + }, + async ({ project }) => { + const p = await resolveProject(project); + const res = await apiResponse.get(`/api/projects/${p.id}/container-stats`); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const s = (await res.json()) as { + running: boolean; + cpu_percent: number; + memory_bytes: number; + memory_limit_bytes: number; + memory_percent: number; + network_rx_bytes: number; + network_tx_bytes: number; + block_read_bytes: number; + block_write_bytes: number; + pids: number; + }; + if (!s.running) { + return { + content: [{ type: "text", text: `${p.name}: not running (zeroed counters returned).` }], + }; + } + const memLimit = s.memory_limit_bytes > 0 ? formatBytes(s.memory_limit_bytes) : "unlimited"; + const lines = [ + `${p.name}: CPU ${s.cpu_percent}% | Memory ${formatBytes(s.memory_bytes)} / ${memLimit} (${s.memory_percent}%) | PIDs ${s.pids}`, + `Network: rx ${formatBytes(s.network_rx_bytes)} / tx ${formatBytes(s.network_tx_bytes)}`, + `Block I/O: read ${formatBytes(s.block_read_bytes)} / write ${formatBytes(s.block_write_bytes)}`, + ]; + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_project_history", + { + title: "Project History (stored)", + description: + "Stored resource history + lifecycle events for one project over a time window — answers 'what was going on with this project around this case?' (NOT live: use moor_project_stats for a current snapshot). Resource samples are taken ~every minute; CPU is averaged across each interval and network/block reported as rates, both computed from raw counters and reset-aware. Events come from the Docker event stream (start/die/oom/kill/restart) and moor's own state changes. Window defaults to the last `hours` (24); pass from_ms/to_ms (epoch ms) for an exact window. A gap warning means events may be incomplete in that window.", + inputSchema: z.object({ + project: z.string().describe("Project name or ID"), + hours: z + .number() + .optional() + .describe("Lookback window in hours (default 24). Ignored if from_ms/to_ms are given."), + from_ms: z.number().optional().describe("Window start, epoch milliseconds"), + to_ms: z.number().optional().describe("Window end, epoch milliseconds"), + }), + }, + async ({ project, hours, from_ms, to_ms }) => { + const p = await resolveProject(project); + const to = to_ms ?? Date.now(); + const from = from_ms ?? to - (hours ?? 24) * 3_600_000; + const res = await apiResponse.get( + `/api/projects/${p.id}/stats/history?from=${from}&to=${to}`, + ); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const h = (await res.json()) as { + from_ms: number; + to_ms: number; + events: Array<{ occurred_at_ms: number; source: string; action: string }>; + summary: { + sample_count: number; + running_sample_count: number; + cpu_percent_avg: number | null; + cpu_percent_max: number | null; + mem_bytes_max: number | null; + net_rx_bytes_total: number; + net_tx_bytes_total: number; + event_counts: Record; + has_gap: boolean; + }; + }; + const s = h.summary; + const windowH = Math.round(((h.to_ms - h.from_ms) / 3_600_000) * 10) / 10; + const lines = [ + `${p.name} history — window ~${windowH}h${s.has_gap ? " [⚠ event gap recorded: events may be incomplete]" : ""}`, + `Samples: ${s.sample_count} total, ${s.running_sample_count} running`, + `CPU: avg ${s.cpu_percent_avg ?? "n/a"}% / max ${s.cpu_percent_max ?? "n/a"}%`, + `Memory: max ${s.mem_bytes_max !== null ? formatBytes(s.mem_bytes_max) : "n/a"}`, + `Network: in ${formatBytes(s.net_rx_bytes_total)} / out ${formatBytes(s.net_tx_bytes_total)}`, + ]; + const counts = Object.entries(s.event_counts); + if (counts.length > 0) { + lines.push(`Events: ${counts.map(([a, n]) => `${a} ${n}`).join(", ")}`); + } + const recent = h.events.slice(-8); + if (recent.length > 0) { + lines.push("Recent events:"); + for (const e of recent) { + lines.push(` ${new Date(e.occurred_at_ms).toISOString()} ${e.action} (${e.source})`); + } + } + if (s.sample_count === 0 && h.events.length === 0) { + lines.push("(no stored history in this window)"); + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); +} diff --git a/packages/mcp/src/tools/tools.test.ts b/packages/mcp/src/tools/tools.test.ts new file mode 100644 index 0000000..bd147b6 --- /dev/null +++ b/packages/mcp/src/tools/tools.test.ts @@ -0,0 +1,910 @@ +import { describe, expect, test } from "bun:test"; +import type { McpServer } from "@modelcontextprotocol/server"; +import type { Project } from "../../../contract/src/index"; +import { registerCleanupTools } from "./cleanup"; +import type { SseReadResult, ToolContext } from "./context"; +import { registerCredentialTools } from "./credentials"; +import { registerEnvTools } from "./env"; +import { registerExecTools } from "./exec"; +import { registerProjectTools } from "./projects"; +import { registerRunTools } from "./runs"; +import { registerServerTools } from "./server"; +import { registerUpdateTools } from "./update"; + +type Registrar = (server: McpServer, client: ToolContext) => void; +type ToolHandler = (input: unknown) => unknown | Promise; +type ToolDefinition = { + inputSchema?: unknown; + handler: ToolHandler; +}; +type SafeParseResult = + | { success: true; data: unknown } + | { success: false; error: { issues?: Array<{ message: string }>; message: string } }; +type SafeParsable = { + safeParse(input: unknown): SafeParseResult; +}; +type ApiMethod = "GET" | "POST" | "PUT" | "DELETE"; +type ApiCall = { + method: ApiMethod; + path: string; + body?: unknown; +}; +type ApiResponder = (body: unknown) => Response | Promise; + +class TestMcpServer { + readonly tools = new Map(); + + registerTool(name: string, config: { inputSchema?: unknown }, handler: ToolHandler): void { + this.tools.set(name, { + inputSchema: config.inputSchema, + handler, + }); + } + + async call(name: string, input: Record = {}): Promise { + const tool = this.tools.get(name); + if (!tool) throw new Error(`Tool not registered: ${name}`); + const parsed = parseInput(tool.inputSchema, input); + return await tool.handler(parsed); + } +} + +class MockApi { + readonly calls: ApiCall[] = []; + private readonly responders = new Map(); + + readonly apiResponse: ToolContext["apiResponse"] = { + get: (path) => this.request("GET", path), + post: (path, body) => this.request("POST", path, body), + put: (path, body) => this.request("PUT", path, body), + delete: (path) => this.request("DELETE", path), + }; + + on(method: ApiMethod, path: string, responder: ApiResponder): void { + this.responders.set(routeKey(method, path), responder); + } + + async request(method: ApiMethod, path: string, body?: unknown): Promise { + this.calls.push(body === undefined ? { method, path } : { method, path, body }); + const responder = this.responders.get(routeKey(method, path)); + if (!responder) throw new Error(`No mock response for ${method} ${path}`); + return await responder(body); + } +} + +function routeKey(method: ApiMethod, path: string): string { + return `${method} ${path}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSafeParsable(value: unknown): value is SafeParsable { + return isRecord(value) && typeof value.safeParse === "function"; +} + +function parseInput(schema: unknown, input: unknown): unknown { + if (schema === undefined) return input; + if (!isSafeParsable(schema)) throw new Error("Unsupported tool input schema in test"); + const result = schema.safeParse(input); + if (result.success) return result.data; + const issueText = result.error.issues?.map((issue) => issue.message).join("; "); + throw new Error(issueText || result.error.message); +} + +function json(body: unknown, init?: ResponseInit): Response { + return Response.json(body, init); +} + +function errorJson(message: string, status = 400): Response { + return json({ error: message }, { status }); +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +async function readErrorMessage(res: Response): Promise { + const text = await res.text(); + if (!text) return `HTTP ${res.status}`; + try { + const parsed = JSON.parse(text) as unknown; + if (isRecord(parsed) && "error" in parsed) { + const error = parsed.error; + return typeof error === "string" ? error : JSON.stringify(error); + } + } catch { + return text; + } + return text; +} + +function projectFixture(overrides: Partial = {}): Project { + return { + id: 7, + name: "app", + github_url: "https://github.com/owner/app", + docker_image: null, + branch: "main", + dockerfile: "Dockerfile", + image_tag: "moor-app:latest", + container_id: "abc123", + status: "running", + domain: "app.example.com", + domain_port: 3000, + restart_policy: "unless-stopped", + memory_limit_mb: null, + cpus: null, + source_credential_id: null, + command: null, + entrypoint: null, + live_status: "running", + live_exit_code: null, + live_checked_at: "2026-07-06T12:00:00Z", + live_error: null, + created_at: "2026-07-06T11:00:00Z", + ...overrides, + }; +} + +function createHarness( + register: Registrar, + options: { + projects?: Project[]; + resolveError?: Error; + sse?: SseReadResult; + } = {}, +): { api: MockApi; server: TestMcpServer; setSse(result: SseReadResult): void } { + const api = new MockApi(); + const server = new TestMcpServer(); + const projects = options.projects ?? [projectFixture()]; + let sseResult = options.sse ?? { logs: "" }; + + const client: ToolContext = { + apiResponse: api.apiResponse, + resolveProject: async (name) => { + if (options.resolveError) throw options.resolveError; + const match = projects.find((p) => p.name === name || String(p.id) === name); + if (!match) throw new Error(`Project "${name}" not found`); + return match; + }, + readErrorMessage, + readSSE: async () => sseResult, + }; + + register(server as unknown as McpServer, client); + return { + api, + server, + setSse(result) { + sseResult = result; + }, + }; +} + +function toolText(result: unknown): string { + if (!isRecord(result) || !Array.isArray(result.content)) { + throw new Error("Tool result did not include content"); + } + const first = result.content[0]; + if (!isRecord(first) || first.type !== "text" || typeof first.text !== "string") { + throw new Error("Tool result did not include text content"); + } + return first.text; +} + +function isErrorResult(result: unknown): boolean { + return isRecord(result) && result.isError === true; +} + +function structuredContent(result: unknown): unknown { + if (!isRecord(result)) return undefined; + return result.structuredContent; +} + +describe("project tools", () => { + test("moor_status renders a compact project list", async () => { + const { api, server } = createHarness(registerProjectTools); + api.on("GET", "/api/projects", () => + json([ + projectFixture({ + name: "app", + docker_image: "ghcr.io/acme/app:latest", + github_url: null, + live_status: "error", + live_exit_code: 137, + live_error: "container missing", + }), + ]), + ); + + const result = await server.call("moor_status"); + const summary = JSON.parse(toolText(result)) as Array>; + + expect(summary).toEqual([ + { + name: "app", + status: "running", + live_status: "error", + live_exit_code: 137, + live_checked_at: "2026-07-06T12:00:00Z", + live_error: "container missing", + source: "ghcr.io/acme/app:latest", + domain: "app.example.com", + }, + ]); + }); + + test("project create validates names before making API calls", async () => { + const { api, server } = createHarness(registerProjectTools); + + await expect( + server.call("moor_project_create", { name: "-bad", docker_image: "nginx:alpine" }), + ).rejects.toThrow("name must start with an alphanumeric character"); + expect(api.calls).toHaveLength(0); + }); + + test("project create requires exactly one source", async () => { + const { api, server } = createHarness(registerProjectTools); + + await expect(server.call("moor_project_create", { name: "app" })).rejects.toThrow( + "Provide exactly one of github_url or docker_image", + ); + expect(api.calls).toHaveLength(0); + }); + + test("project create shapes volume input into separate API calls", async () => { + const { api, server } = createHarness(registerProjectTools); + api.on("POST", "/api/projects", () => json({ id: 10, name: "app" }, { status: 201 })); + api.on("POST", "/api/projects/10/volumes", () => + json({ id: 2, name: "data", target: "/data", docker_name: "moor-app-data" }, { status: 201 }), + ); + + const result = await server.call("moor_project_create", { + name: "app", + docker_image: "nginx:alpine", + volumes: [{ name: "data", target: "/data" }], + }); + + expect(api.calls).toEqual([ + { + method: "POST", + path: "/api/projects", + body: { name: "app", docker_image: "nginx:alpine" }, + }, + { + method: "POST", + path: "/api/projects/10/volumes", + body: { name: "data", target: "/data" }, + }, + ]); + expect(toolText(result)).toContain("Created volumes: data"); + }); + + test("moor_deploy rejects non-repo GitHub URLs before side effects", async () => { + const { api, server } = createHarness(registerProjectTools); + + await expect( + server.call("moor_deploy", { + name: "app", + github_url: "https://github.com/owner/repo/tree/main", + }), + ).rejects.toThrow("github_url must point to /owner/repo"); + expect(api.calls).toHaveLength(0); + }); + + test("moor_deploy normalizes domains and surfaces create errors from JSON", async () => { + const { api, server } = createHarness(registerProjectTools, { projects: [] }); + api.on("GET", "/api/server/drain", () => + json({ state: { enabled: false, reason: null, expires_at: null } }), + ); + api.on("GET", "/api/projects", () => json([])); + api.on("POST", "/api/projects", () => errorJson("domain already routed", 409)); + + await expect( + server.call("moor_deploy", { + name: "app", + docker_image: "nginx:alpine", + domain: " App.Example.COM ", + }), + ).rejects.toThrow("[create] domain already routed"); + + expect( + api.calls.find((call) => call.path === "/api/projects" && call.method === "POST"), + ).toEqual({ + method: "POST", + path: "/api/projects", + body: { + name: "app", + github_url: undefined, + docker_image: "nginx:alpine", + branch: undefined, + dockerfile: undefined, + domain: "app.example.com", + domain_port: undefined, + restart_policy: undefined, + memory_limit_mb: undefined, + cpus: undefined, + source_credential_id: undefined, + command: undefined, + entrypoint: undefined, + }, + }); + }); + + test("moor_deploy renders create plus run output", async () => { + const { api, server, setSse } = createHarness(registerProjectTools, { projects: [] }); + setSse({ logs: "pull complete\nstarted\n" }); + api.on("GET", "/api/server/drain", () => + json({ state: { enabled: false, reason: null, expires_at: null } }), + ); + api.on("GET", "/api/projects", () => json([])); + api.on("POST", "/api/projects", () => + json( + projectFixture({ id: 10, name: "app", docker_image: "nginx:alpine", github_url: null }), + { + status: 201, + }, + ), + ); + api.on("POST", "/api/projects/10/run", () => new Response("event: done\n")); + + const result = await server.call("moor_deploy", { + name: "app", + docker_image: "nginx:alpine", + }); + + expect(toolText(result)).toBe( + "Created project app (id=10).\n\nBuild/run output:\npull complete\nstarted\n", + ); + }); +}); + +describe("run and log tools", () => { + test("moor_logs distinguishes projects with no container", async () => { + const { api, server } = createHarness(registerRunTools); + api.on("GET", "/api/projects/7/logs?tail=100", () => json({ logs: "", state: "no_container" })); + + const result = await server.call("moor_logs", { project: "app" }); + + expect(toolText(result)).toBe("(project hasn't been started yet \u2014 no container)"); + }); + + test("moor_logs surfaces API error JSON and Docker 502 details", async () => { + const { api, server } = createHarness(registerRunTools); + api.on("GET", "/api/projects/7/logs?tail=25", () => errorJson("container inspect failed", 500)); + api.on("GET", "/api/projects/7/logs?tail=50", () => errorJson("daemon unavailable", 502)); + + await expect(server.call("moor_logs", { project: "app", lines: 25 })).rejects.toThrow( + "Failed: 500 container inspect failed", + ); + await expect(server.call("moor_logs", { project: "app", lines: 50 })).rejects.toThrow( + "Docker error: daemon unavailable", + ); + }); + + test("moor_rebuild surfaces pre-stream run errors", async () => { + const { api, server } = createHarness(registerRunTools); + api.on("POST", "/api/projects/7/run?nocache=true", () => errorJson("drain mode active", 503)); + + await expect(server.call("moor_rebuild", { project: "app", no_cache: true })).rejects.toThrow( + "[run] drain mode active", + ); + }); + + test("moor_rebuild returns structured build failures as tool errors", async () => { + const { api, server, setSse } = createHarness(registerRunTools); + setSse({ + logs: "clone failed\n", + structuredError: { + code: "source_credential_required", + message: "private repo requires a credential", + }, + }); + api.on("POST", "/api/projects/7/run", () => new Response("event: log\n")); + + const result = await server.call("moor_rebuild", { project: "app" }); + + expect(isErrorResult(result)).toBe(true); + expect(toolText(result)).toBe( + "rebuild failed: code=source_credential_required message=private repo requires a credential", + ); + expect(structuredContent(result)).toEqual({ + code: "source_credential_required", + message: "private repo requires a credential", + }); + }); + + test("moor_runs renders compact run list rows", async () => { + const { api, server } = createHarness(registerRunTools); + api.on("GET", "/api/projects/7/runs?include_output=false&page=2", () => + json({ + total: 22, + runs: [ + { + id: 55, + cron_id: 3, + cron_name: "nightly", + cron_command: "bun run job", + started_at: "2026-07-06T12:00:00Z", + finished_at: null, + exit_code: null, + duration_ms: null, + stdout_bytes: 10, + stderr_bytes: 0, + stdout_total_bytes: 4096, + stderr_total_bytes: 0, + }, + { + id: 54, + cron_id: null, + cron_name: null, + cron_command: null, + started_at: "2026-07-06T11:00:00Z", + finished_at: "2026-07-06T11:01:05Z", + exit_code: 1, + duration_ms: 65_000, + stdout_bytes: 4, + stderr_bytes: 9, + }, + ], + }), + ); + + const result = await server.call("moor_runs", { project: "app", page: 2 }); + + expect(toolText(result)).toBe( + [ + "app: 2 run(s) on page 2, 22 total. Use moor_run_get(run_id) for stored output (build/manual rows are tail-truncated; total bytes shown below).", + 'id=55 cron(nightly) running dur=\u2014 stdout=4096B stderr=0B started=2026-07-06T12:00:00Z cmd="bun run job"', + "id=54 build_or_manual failed exit=1 dur=1m5s stdout=4B stderr=9B started=2026-07-06T11:00:00Z", + ].join("\n"), + ); + }); + + test("moor_run_get renders detail metadata and tail-truncated streams", async () => { + const { api, server } = createHarness(registerRunTools); + api.on("GET", "/api/runs/55", () => + json({ + id: 55, + cron_id: null, + cron_name: null, + cron_command: null, + started_at: "2026-07-06T12:00:00Z", + finished_at: "2026-07-06T12:00:03Z", + exit_code: 0, + duration_ms: 3000, + stdout: "0123456789", + stderr: "abc", + stdout_total_bytes: 20, + stderr_total_bytes: 3, + }), + ); + + const result = await server.call("moor_run_get", { run_id: 55, tail_bytes: 4 }); + const text = toolText(result); + + expect(text).toContain("run_id=55 build_or_manual success exit_code=0 duration=3s"); + expect(text).toContain("started_at: 2026-07-06T12:00:00Z"); + expect(text).toContain( + "stdout (showing last 4 chars of 10 stored bytes; 20 total bytes seen):\n6789", + ); + expect(text).toContain("stderr:\nabc"); + }); +}); + +describe("exec tools", () => { + test("moor_exec requires a command", async () => { + const { server } = createHarness(registerExecTools); + + await expect(server.call("moor_exec", { project: "app" })).rejects.toThrow("expected string"); + }); + + test("moor_exec sends timeout_ms only when provided and formats output", async () => { + const { api, server } = createHarness(registerExecTools); + api.on("POST", "/api/projects/7/exec", () => + json({ exitCode: 2, stdout: "out\n", stderr: "err\n" }), + ); + + const result = await server.call("moor_exec", { + project: "app", + command: "false", + timeout_ms: 2000, + }); + + expect(api.calls[0]).toEqual({ + method: "POST", + path: "/api/projects/7/exec", + body: { command: "false", timeout_ms: 2000 }, + }); + expect(toolText(result)).toBe("out\n\n[stderr] err\n\n[exit code: 2]"); + }); + + test("moor_exec surfaces API error JSON and timeout kill details", async () => { + const { api, server } = createHarness(registerExecTools); + api.on("POST", "/api/projects/7/exec", (body) => { + if (isRecord(body) && body.command === "sleep 10") { + return json( + { + timeout_ms: 1000, + killed: true, + killed_pid: "42", + live_remaining: 0, + message: "timed out", + }, + { status: 504 }, + ); + } + return errorJson("container is not running", 409); + }); + + await expect(server.call("moor_exec", { project: "app", command: "pwd" })).rejects.toThrow( + "Failed: container is not running", + ); + await expect( + server.call("moor_exec", { project: "app", command: "sleep 10", timeout_ms: 1000 }), + ).rejects.toThrow("Exec timed out after 1000ms. Process tree terminated"); + }); + + test("exec tools surface clear connection errors from the mocked client", async () => { + const { server } = createHarness(registerExecTools, { + resolveError: new Error("Cannot reach moor: connect ECONNREFUSED"), + }); + + await expect(server.call("moor_exec", { project: "app", command: "pwd" })).rejects.toThrow( + "Cannot reach moor: connect ECONNREFUSED", + ); + }); +}); + +describe("env, cron, volume, and file tools", () => { + test("moor_env_set requires vars", async () => { + const { server } = createHarness(registerEnvTools); + + await expect(server.call("moor_env_set", { project: "app" })).rejects.toThrow( + "expected record", + ); + }); + + test("moor_env_set merges with existing envs and restarts running projects", async () => { + const { api, server } = createHarness(registerEnvTools); + api.on("GET", "/api/projects/7/envs", () => json([{ key: "A", value: "1" }])); + api.on("PUT", "/api/projects/7/envs", () => noContent()); + api.on("POST", "/api/projects/7/stop", () => noContent()); + api.on("POST", "/api/projects/7/start", () => noContent()); + + const result = await server.call("moor_env_set", { + project: "app", + vars: { B: "2" }, + }); + + expect(api.calls[1]).toEqual({ + method: "PUT", + path: "/api/projects/7/envs", + body: [ + { key: "A", value: "1" }, + { key: "B", value: "2" }, + ], + }); + expect(toolText(result)).toBe("Set B on app. Container restarted."); + }); + + test("moor_env_set surfaces JSON errors from the env write", async () => { + const { api, server } = createHarness(registerEnvTools); + api.on("GET", "/api/projects/7/envs", () => json([])); + api.on("PUT", "/api/projects/7/envs", () => errorJson("env key is invalid", 400)); + + await expect( + server.call("moor_env_set", { project: "app", vars: { "BAD KEY": "x" } }), + ).rejects.toThrow("Failed to set envs: env key is invalid"); + }); + + test("cron create validates unsupported schedules before resolving the project", async () => { + const { api, server } = createHarness(registerEnvTools); + + await expect( + server.call("moor_cron_create", { + project: "missing", + name: "nightly", + schedule: "0 0 * * 7", + command: "echo hi", + }), + ).rejects.toThrow("Invalid schedule: day-of-week: 7 out of bounds [0-6]"); + expect(api.calls).toHaveLength(0); + }); + + test("cron update shapes enabled into the API's numeric flag", async () => { + const { api, server } = createHarness(registerEnvTools); + api.on("PUT", "/api/crons/3", () => + json({ + id: 3, + enabled: 1, + name: "nightly", + schedule: "0 3 * * *", + command: "echo hi", + }), + ); + + const result = await server.call("moor_cron_update", { + cron_id: 3, + enabled: true, + }); + + expect(api.calls[0]).toEqual({ + method: "PUT", + path: "/api/crons/3", + body: { enabled: 1 }, + }); + expect(toolText(result)).toContain('"enabled": 1'); + }); + + test("file set requires exactly one content source via API error text", async () => { + const { api, server } = createHarness(registerEnvTools); + api.on("POST", "/api/projects/7/files", () => + errorJson("provide exactly one of content or env_ref", 400), + ); + + await expect( + server.call("moor_file_set", { + project: "app", + path: "/etc/app.conf", + content: "inline", + env_ref: "APP_CONF", + }), + ).rejects.toThrow("Failed: provide exactly one of content or env_ref"); + }); +}); + +describe("server tools", () => { + test("moor_stats renders server and Docker disk stats", async () => { + const { api, server } = createHarness(registerServerTools); + api.on("GET", "/api/server/stats", () => + json({ + hostname: "moorbox", + os: "linux", + uptime: "1 day", + cpu: { percent: 25, cores: 4 }, + load: { one_min: 1.25, cores: 4, normalized_percent: 31 }, + memory: { total: "8 GB", used: "3 GB", percent: 38 }, + disk: { total: "100 GB", used: "50 GB", percent: 50 }, + disks: [{ mount: "/", total: "100 GB", used: "50 GB", percent: 50, label: "root" }], + containers: { running: 2, total: 3 }, + docker: { + images: { bytes: 1536, reclaimable_bytes: 512, count: 4, unused_count: 1 }, + containers: { bytes: 0, reclaimable_bytes: 0, count: 3, stopped_count: 1 }, + volumes: { bytes: 10 * 1024 * 1024, reclaimable_bytes: 0, count: 2, unused_count: 0 }, + build_cache: { bytes: 3 * 1024 ** 3, reclaimable_bytes: 1024, count: 8 }, + }, + }), + ); + + const result = await server.call("moor_stats"); + const text = toolText(result); + + expect(text).toContain("Host: moorbox"); + expect(text).toContain("Load (1m): 1.25 on 4 cores (31%)"); + expect(text).toContain("root (/): 50 GB / 100 GB (50%)"); + expect(text).toContain("Images: 1.5 KB (512 B reclaimable, 1/4 unused)"); + expect(text).toContain("Volumes: 10 MB (0 B reclaimable, 0/2 unused)"); + expect(text).toContain("Build cache: 3.0 GB (1.0 KB reclaimable, 8 entries)"); + }); + + test("moor_drain_status renders drain state and active work", async () => { + const { api, server } = createHarness(registerServerTools); + api.on("GET", "/api/server/drain", () => + json({ + state: { + enabled: true, + reason: "updating moor", + started_at: "2026-07-06T12:00:00Z", + expires_at: "2026-07-06T12:30:00Z", + clear_after_version: null, + }, + active_work: { + builds_in_flight: 1, + execs_in_flight: 2, + crons_in_flight: 0, + terminals_open: 3, + }, + }), + ); + + expect(toolText(await server.call("moor_drain_status"))).toBe( + [ + "drain: ON (reason: updating moor)", + " started_at: 2026-07-06T12:00:00Z", + " expires_at: 2026-07-06T12:30:00Z (auto-clear)", + "active: builds=1 execs=2 crons=0 terminals=3", + ].join("\n"), + ); + }); + + test("moor_drain_enable surfaces JSON errors", async () => { + const { api, server } = createHarness(registerServerTools); + api.on("POST", "/api/server/drain/enable", () => errorJson("ttl_minutes is invalid", 400)); + + await expect( + server.call("moor_drain_enable", { reason: "test", ttl_minutes: -1 }), + ).rejects.toThrow("drain enable failed: 400 ttl_minutes is invalid"); + }); +}); + +describe("credential tools", () => { + test("source credential check requires github_url", async () => { + const { server } = createHarness(registerCredentialTools); + + await expect(server.call("moor_source_credential_check", {})).rejects.toThrow( + "expected string", + ); + }); + + test("source credential check returns structured API failures as tool errors", async () => { + const { api, server } = createHarness(registerCredentialTools); + api.on("POST", "/api/server/source-credentials/check", () => + json( + { + code: "multiple_credentials", + reason: "choose a credential", + candidates: [1, 2], + }, + { status: 409 }, + ), + ); + + const result = await server.call("moor_source_credential_check", { + github_url: "https://github.com/owner/private", + }); + + expect(isErrorResult(result)).toBe(true); + expect(toolText(result)).toBe( + "check failed: code=multiple_credentials reason=choose a credential", + ); + expect(structuredContent(result)).toEqual({ + code: "multiple_credentials", + reason: "choose a credential", + candidates: [1, 2], + }); + }); + + test("registry credential update validates that at least one field changes", async () => { + const { api, server } = createHarness(registerCredentialTools); + + await expect(server.call("moor_registry_credential_update", { id: 1 })).rejects.toThrow( + "must provide at least one of username or secret to update", + ); + expect(api.calls).toHaveLength(0); + }); +}); + +describe("cleanup tools", () => { + test("cleanup plan renders candidates and candidates_json", async () => { + const { api, server } = createHarness(registerCleanupTools); + api.on("POST", "/api/server/cleanup/plan", () => + json({ + total_reclaimable_bytes: 10 * 1024 * 1024, + candidates: [ + { category: "build_cache", reclaimable_bytes: 1536, label: "all" }, + { + category: "dangling_image", + id: "sha256:abc", + reclaimable_bytes: 1024, + repo_tags: [], + label: "abc", + }, + ], + }), + ); + + const result = await server.call("moor_cleanup_plan", { scope: ["build_cache"] }); + const text = toolText(result); + + expect(api.calls[0]).toEqual({ + method: "POST", + path: "/api/server/cleanup/plan", + body: { scope: ["build_cache"] }, + }); + expect(text).toContain("2 candidate(s), total reclaimable: 10 MB."); + expect(text).toContain("build_cache [all] \u2014 1.5 KB reclaimable"); + expect(text).toContain("dangling_image [abc] id=sha256:abc 1.0 KB"); + expect(text).toContain("candidates_json:"); + }); + + test("cleanup execute requires at least one candidate", async () => { + const { server } = createHarness(registerCleanupTools); + + await expect(server.call("moor_cleanup_execute", { candidates: [] })).rejects.toThrow( + "Too small", + ); + }); + + test("cleanup execute surfaces JSON API errors", async () => { + const { api, server } = createHarness(registerCleanupTools); + api.on("POST", "/api/server/cleanup/execute", () => errorJson("docker prune failed", 500)); + + await expect( + server.call("moor_cleanup_execute", { candidates: [{ category: "build_cache" }] }), + ).rejects.toThrow("execute failed: 500 docker prune failed"); + }); +}); + +describe("update tools", () => { + test("moor_update_status renders preflight state", async () => { + const { api, server } = createHarness(registerUpdateTools); + api.on("GET", "/api/server/update-status", () => + json({ + current: { + version: "0.53.0", + image_id: "sha256:local", + repo_digest: null, + started_at: "2026-07-06T12:00:00Z", + }, + available: { + latest_tag: "latest", + latest_digest: null, + update_available: null, + registry_error: "timeout", + }, + active_work: { + builds_in_flight: 1, + execs_in_flight: 0, + crons_in_flight: 0, + terminals_open: 0, + }, + db_backup: { + last_backup_at: null, + age_seconds: null, + location: null, + }, + safe_to_update: false, + unsafe_reasons: ["builds in flight"], + recommended_command: "docker compose pull moor && docker compose up -d moor", + }), + ); + + expect(toolText(await server.call("moor_update_status"))).toBe( + [ + "moor 0.53.0 (image_id: sha256:local)", + "repo_digest: (none \u2014 locally built or stale inspect)", + "update availability unknown \u2014 registry unreachable: timeout", + "active: builds=1 execs=0 crons=0 terminals=0", + "safe_to_update: NO", + " - builds in flight", + "recommended: docker compose pull moor && docker compose up -d moor", + ].join("\n"), + ); + }); + + test("moor_update_apply surfaces structured refusal details", async () => { + const { api, server } = createHarness(registerUpdateTools); + api.on("POST", "/api/server/update/apply", () => + json( + { + error: { + code: "unsafe", + reason: "active work is running", + unsafe_reasons: ["1 build in flight", "1 exec in flight"], + }, + }, + { status: 409 }, + ), + ); + + await expect(server.call("moor_update_apply", { bypass: ["unknown_digest"] })).rejects.toThrow( + "moor_update_apply refused [unsafe]: active work is running\nunsafe_reasons:\n - 1 build in flight\n - 1 exec in flight", + ); + }); + + test("moor_update_audit passes limit and renders empty audit list", async () => { + const { api, server } = createHarness(registerUpdateTools); + api.on("GET", "/api/server/update/audit?limit=5", () => json({ rows: [] })); + + const result = await server.call("moor_update_audit", { limit: 5, tail_bytes: 0 }); + + expect(api.calls[0]).toEqual({ + method: "GET", + path: "/api/server/update/audit?limit=5", + }); + expect(toolText(result)).toContain("no update attempts recorded yet"); + }); +}); diff --git a/packages/mcp/src/tools/update.ts b/packages/mcp/src/tools/update.ts new file mode 100644 index 0000000..0b8496b --- /dev/null +++ b/packages/mcp/src/tools/update.ts @@ -0,0 +1,179 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { + MAX_LOG_TAIL_BYTES, + renderAuditList, + type UpdateAuditApiRow, +} from "../update-audit-render"; +import type { ToolContext } from "./context"; +export function registerUpdateTools(server: McpServer, client: ToolContext): void { + const { apiResponse, readErrorMessage } = client; + + server.registerTool( + "moor_update_status", + { + title: "Update status / preflight", + description: + "Report moor's current version + image digest, the latest available digest on GHCR, active in-flight work counts, DB backup recency, and a safe_to_update boolean. update_available is null (not false) when either the local repo_digest or the registry digest is unknown — never lies by comparing across identifier spaces. unsafe_reasons is a human-readable array; render inline rather than re-deriving from booleans. Read-only diagnostic — does NOT perform any update.", + }, + async () => { + const res = await apiResponse.get("/api/server/update-status"); + if (!res.ok) throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`); + const s = (await res.json()) as { + current: { + version: string; + image_id: string | null; + repo_digest: string | null; + started_at: string; + }; + available: { + latest_tag: string; + latest_digest: string | null; + update_available: boolean | null; + registry_error: string | null; + }; + active_work: { + builds_in_flight: number; + execs_in_flight: number; + crons_in_flight: number; + terminals_open: number; + }; + db_backup: { + last_backup_at: string | null; + age_seconds: number | null; + location: string | null; + }; + safe_to_update: boolean; + unsafe_reasons: string[]; + recommended_command: string; + }; + const lines: string[] = []; + lines.push(`moor ${s.current.version} (image_id: ${s.current.image_id ?? "unknown"})`); + lines.push( + `repo_digest: ${s.current.repo_digest ?? "(none — locally built or stale inspect)"}`, + ); + + if (s.available.update_available === true) { + lines.push(`update AVAILABLE → latest: ${s.available.latest_digest}`); + } else if (s.available.update_available === false) { + lines.push(`up to date (latest: ${s.available.latest_digest})`); + } else { + // null — explain WHICH side is unknown. + const why = s.available.registry_error + ? `registry unreachable: ${s.available.registry_error}` + : s.current.repo_digest === null + ? "no local repo_digest (built locally?)" + : "comparison unavailable"; + lines.push(`update availability unknown — ${why}`); + } + + lines.push( + `active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`, + ); + + if (s.safe_to_update) { + lines.push("safe_to_update: YES"); + } else { + lines.push("safe_to_update: NO"); + for (const r of s.unsafe_reasons) lines.push(` - ${r}`); + } + lines.push(`recommended: ${s.recommended_command}`); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + ); + + server.registerTool( + "moor_update_apply", + { + title: "Apply moor update (transient respawner)", + description: + "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll via moor_update_audit. Outcomes: success | failed (pull failed pre-replacement) | rolled_back (up/health failed, automatic rollback succeeded) | rollback_failed (rollback also failed — manual recovery needed) | crashed (no marker after 30-min grace). Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.", + inputSchema: z.object({ + target_digest: z + .string() + .regex(/^sha256:[0-9a-f]{64}$/, "target_digest must be sha256:<64 hex>") + .optional() + .describe( + "Pin the update to this exact image digest. Default: the registry's current `:latest` digest from moor_update_status.", + ), + bypass: z + .array(z.enum(["active_work", "unknown_digest"])) + .optional() + .describe( + "Per-blocker bypass. `active_work` accepts that in-flight builds/execs/crons will be interrupted via the shutdown coordinator. `unknown_digest` accepts proceeding when the registry comparison is inconclusive (locally-built image, GHCR unreachable). Backup is mandatory and not in this list.", + ), + }), + }, + async (input) => { + const res = await apiResponse.post("/api/server/update/apply", input ?? {}); + if (res.status === 202) { + const { audit_id } = (await res.json()) as { audit_id: number }; + return { + content: [ + { + type: "text", + text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_audit to watch the outcome, or moor_update_status to watch the version. Possible terminal states: + - success (new image healthy) + - failed (pull failed before moor was replaced) + - rolled_back (up/health failed; automatic rollback succeeded; drain stays on) + - rollback_failed (up/health failed AND rollback failed; manual recovery) + - crashed (no marker after 30-min grace; respawner died) + Recovery: rolled_back means moor is on the previous image again; the failed update is captured in error_log. rollback_failed or crashed mean an operator should investigate (likely manual docker compose up).`, + }, + ], + }; + } + // Error: surface the structured reason so callers can act on it. + const body = (await res.json().catch(() => ({}))) as { + error?: { code: string; reason?: string; unsafe_reasons?: string[] }; + }; + const code = body.error?.code ?? `HTTP ${res.status}`; + const reason = body.error?.reason ?? "no detail"; + const extra = body.error?.unsafe_reasons + ? `\nunsafe_reasons:\n - ${body.error.unsafe_reasons.join("\n - ")}` + : ""; + throw new Error(`moor_update_apply refused [${code}]: ${reason}${extra}`); + }, + ); + + server.registerTool( + "moor_update_audit", + { + title: "Update history (audit log)", + description: + "Read-only: recent moor_update_apply attempts and their outcomes. Each row shows audit_id, state (success | failed | rolled_back | rollback_failed | in_progress | crashed), duration, digest deltas, backup path, and any error logs. error_log preserves the ORIGINAL apply failure (never overwritten by rollback step details); rollback_error is set only on rollback_failed. Default tail is 4 KiB per log field; pass tail_bytes=0 to omit log bodies entirely (keeps the metadata line and replaces the body with a sized marker), or up to 16384 to read more.", + inputSchema: z.object({ + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe("How many most-recent attempts to return. Default 20, max 200."), + tail_bytes: z + .number() + .int() + .min(0) + .max(MAX_LOG_TAIL_BYTES) + .optional() + .describe( + "Max bytes of error_log and rollback_error returned inline per row. Default 4096 (4 KiB). 0 to omit log bodies entirely; 16384 max.", + ), + }), + }, + async ({ limit, tail_bytes }) => { + const qs = new URLSearchParams(); + if (limit !== undefined) qs.set("limit", String(limit)); + const path = qs.toString() + ? `/api/server/update/audit?${qs.toString()}` + : "/api/server/update/audit"; + const res = await apiResponse.get(path); + if (!res.ok) + throw new Error(`update audit failed: ${res.status} ${await readErrorMessage(res)}`); + const { rows } = (await res.json()) as { rows: UpdateAuditApiRow[] }; + return { + content: [{ type: "text", text: renderAuditList(rows, { tail_bytes }) }], + }; + }, + ); +} diff --git a/scripts/release/monorepo-shared.js b/scripts/release/monorepo-shared.js new file mode 100644 index 0000000..ed301e8 --- /dev/null +++ b/scripts/release/monorepo-shared.js @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import path from "node:path"; +import { getRoot } from "semantic-release-monorepo/src/git-utils.js"; +import logPluginVersion from "semantic-release-monorepo/src/log-plugin-version.js"; +import { withFiles } from "semantic-release-monorepo/src/only-package-commits.js"; +import { + mapCommits, + mapNextReleaseVersion, + withOptionsTransforms, +} from "semantic-release-monorepo/src/options-transforms.js"; +import versionToGitTag from "semantic-release-monorepo/src/version-to-git-tag.js"; +import { wrapStep } from "semantic-release-plugin-decorators"; + +// CLI and MCP prepack bundles include packages/contract source. Contract-only +// changes must therefore trigger releases for those consuming packages. +const DEFAULT_SHARED_PATHS = ["packages/contract"]; +const WRAPPER_NAME = "semantic-release-monorepo"; + +const normalizeGitPath = (value) => { + const normalized = path.normalize(value).replace(/\\/g, "/").replace(/\/+$/, ""); + return normalized === "" ? "." : normalized; +}; + +const pathSegments = (value) => { + const normalized = normalizeGitPath(value); + return normalized === "." ? [] : normalized.split("/").filter(Boolean); +}; + +const fileIsUnderPath = (releasePath, filePath) => { + const releaseSegments = pathSegments(releasePath); + if (releaseSegments.length === 0) return true; + + const fileSegments = pathSegments(filePath); + return releaseSegments.every((segment, index) => segment === fileSegments[index]); +}; + +const uniquePaths = (paths) => Array.from(new Set(paths.map(normalizeGitPath))); + +const getSharedSourcePaths = (env = process.env) => { + const rawPaths = env.MOOR_RELEASE_SHARED_PATHS; + if (rawPaths === undefined) return DEFAULT_SHARED_PATHS; + + return rawPaths + .split(",") + .map((sharedPath) => sharedPath.trim()) + .filter(Boolean); +}; + +const getReleasePaths = (packagePath, env = process.env) => + uniquePaths([packagePath, ...getSharedSourcePaths(env)]); + +const findPackageJsonPath = (fromDirectory = process.cwd()) => { + let directory = path.resolve(fromDirectory); + + while (true) { + const packageJsonPath = path.join(directory, "package.json"); + if (fs.existsSync(packageJsonPath)) return packageJsonPath; + + const parent = path.dirname(directory); + if (parent === directory) { + throw new Error(`Could not find package.json from ${fromDirectory}`); + } + directory = parent; + } +}; + +const readPackageName = () => { + const packageJsonPath = findPackageJsonPath(); + return JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).name; +}; + +const getPackagePath = async () => { + const packageJsonPath = findPackageJsonPath(); + const gitRoot = await getRoot(); + + return normalizeGitPath(path.relative(gitRoot, path.dirname(packageJsonPath))); +}; + +const onlyReleasePathCommits = async (commits) => { + const packagePath = await getPackagePath(); + const releasePaths = getReleasePaths(packagePath); + const commitsWithFiles = await withFiles(commits); + + return commitsWithFiles.filter(({ files }) => isCommitInReleasePaths(releasePaths, files)); +}; + +const tapAsync = (fn) => async (value) => { + await fn(value); + return value; +}; + +const pipeAsync = + (...functions) => + async (initialValue) => { + let value = initialValue; + for (const fn of functions) { + value = await fn(value); + } + return value; + }; + +const composeWrappers = + (...wrappers) => + (plugin) => + wrappers.reduceRight((wrappedPlugin, wrapper) => wrapper(wrappedPlugin), plugin); + +const logFilteredCommitCount = + (logger) => + async ({ commits }) => { + logger.log( + "Found %s commits for package %s since last release", + commits.length, + readPackageName(), + ); + }; + +const withOnlyReleasePathCommits = (plugin) => async (pluginConfig, config) => { + const { logger } = config; + + return plugin( + pluginConfig, + await pipeAsync( + mapCommits(onlyReleasePathCommits), + tapAsync(logFilteredCommitCount(logger)), + )(config), + ); +}; + +const analyzeCommits = wrapStep( + "analyzeCommits", + composeWrappers(logPluginVersion("analyzeCommits"), withOnlyReleasePathCommits), + { + wrapperName: WRAPPER_NAME, + }, +); + +const generateNotes = wrapStep( + "generateNotes", + composeWrappers( + logPluginVersion("generateNotes"), + withOnlyReleasePathCommits, + withOptionsTransforms([mapNextReleaseVersion(versionToGitTag)]), + ), + { + wrapperName: WRAPPER_NAME, + }, +); + +const success = wrapStep( + "success", + composeWrappers( + logPluginVersion("success"), + withOnlyReleasePathCommits, + withOptionsTransforms([mapNextReleaseVersion(versionToGitTag)]), + ), + { + wrapperName: WRAPPER_NAME, + }, +); + +const fail = wrapStep( + "fail", + composeWrappers( + logPluginVersion("fail"), + withOnlyReleasePathCommits, + withOptionsTransforms([mapNextReleaseVersion(versionToGitTag)]), + ), + { + wrapperName: WRAPPER_NAME, + }, +); + +const tagFormat = `${readPackageName()}-v\${version}`; + +const isCommitInReleasePaths = (paths, commitFiles) => + commitFiles.some((filePath) => + paths.some((releasePath) => fileIsUnderPath(releasePath, filePath)), + ); + +export { + analyzeCommits, + fail, + generateNotes, + getReleasePaths, + getSharedSourcePaths, + isCommitInReleasePaths, + success, + tagFormat, +}; diff --git a/scripts/release/monorepo-shared.test.js b/scripts/release/monorepo-shared.test.js new file mode 100644 index 0000000..78a7eed --- /dev/null +++ b/scripts/release/monorepo-shared.test.js @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { getReleasePaths, isCommitInReleasePaths } from "./monorepo-shared.js"; + +describe("isCommitInReleasePaths", () => { + const cliReleasePaths = ["packages/cli", "packages/contract"]; + + test("includes commits touching the package directory", () => { + expect(isCommitInReleasePaths(cliReleasePaths, ["packages/cli/src/index.ts"])).toBe(true); + }); + + test("includes commits touching the shared contract directory", () => { + expect(isCommitInReleasePaths(cliReleasePaths, ["packages/contract/src/client.ts"])).toBe(true); + }); + + test("excludes commits outside the package and shared paths", () => { + expect(isCommitInReleasePaths(cliReleasePaths, ["apps/api/index.ts"])).toBe(false); + }); + + test("uses MOOR_RELEASE_SHARED_PATHS as the shared-path override", () => { + const releasePaths = getReleasePaths("packages/cli", { + MOOR_RELEASE_SHARED_PATHS: "packages/shared, apps/web", + }); + + expect(isCommitInReleasePaths(releasePaths, ["packages/contract/src/client.ts"])).toBe(false); + expect(isCommitInReleasePaths(releasePaths, ["packages/shared/index.ts"])).toBe(true); + expect(isCommitInReleasePaths(releasePaths, ["apps/web/src/main.tsx"])).toBe(true); + }); +}); diff --git a/scripts/release/package.json b/scripts/release/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/scripts/release/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}