Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7950d05
chore(ci): typecheck cli and mcp packages, run tests in CI
caiopizzol Jul 6, 2026
2b8d4f8
feat(contract): shared types, http client, and validators package
caiopizzol Jul 6, 2026
01a59a4
refactor(mcp): use shared contract client and types
caiopizzol Jul 6, 2026
d64a74d
refactor(web): use shared contract types
caiopizzol Jul 6, 2026
329dafa
refactor(api): extract deploy orchestration into deploy service
caiopizzol Jul 6, 2026
e8a2deb
refactor(api): normalize error responses to JSON envelope
caiopizzol Jul 6, 2026
46ef626
refactor(mcp): split tools into per-domain modules
caiopizzol Jul 6, 2026
b7e25b6
test(mcp): cover validators, formatting, and error handling
caiopizzol Jul 6, 2026
e7f9283
docs: generated MCP tool reference, agent workflow guide, deploy conf…
caiopizzol Jul 6, 2026
eea3939
test(api): cover docker adapter build, create, auth, and cleanup paths
caiopizzol Jul 6, 2026
1d6400b
fix: address review findings on cleanup branch
caiopizzol Jul 6, 2026
0edc975
docs: add architecture and surface-hierarchy doc
caiopizzol Jul 6, 2026
cef58c7
refactor(api): ordered user_version migrations replace blind ALTER ca…
caiopizzol Jul 6, 2026
ab450f5
fix(api): strict shared GitHub URL validation and complete JSON error…
caiopizzol Jul 6, 2026
2f3f117
refactor(cli): use shared contract client and types
caiopizzol Jul 6, 2026
70f4e40
refactor(contract): export shared error parser and dedupe client copies
caiopizzol Jul 6, 2026
367d7c0
fix(contract): align GitHub URL validators with the API's strict check
caiopizzol Jul 6, 2026
342326a
fix(build): include contract package in Docker build and release trig…
caiopizzol Jul 6, 2026
ba6cc70
chore(contract): mark internal, drop npm release plumbing
caiopizzol Jul 6, 2026
d9a9221
docs(mcp): fix stale tool descriptions referencing unshipped features
caiopizzol Jul 6, 2026
ce57f97
chore(ci): pack smoke tests and MCP docs staleness check
caiopizzol Jul 6, 2026
b699bc2
fix(release): count contract commits toward cli and mcp releases
caiopizzol Jul 6, 2026
fc6f015
chore(contract): drop publishConfig from private package
caiopizzol Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
branches: [main]
paths:
- "packages/cli/**"
- "packages/contract/**"

jobs:
check:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release-mcp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
branches: [main]
paths:
- "packages/mcp/**"
- "packages/contract/**"

jobs:
check:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-moor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
- "apps/api/**"
- "apps/web/**"
- "apps/respawner/**"
- "packages/contract/**"
- "Dockerfile"
- "docker-compose.yml"
- "package.json"
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
237 changes: 237 additions & 0 deletions apps/api/db-migrations.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
});
});
});
Loading
Loading