From d9ef9b02c48efd6cf45dc9b26b5d16210b860dd8 Mon Sep 17 00:00:00 2001 From: Lftobs Date: Sat, 24 Oct 2026 01:27:03 +0100 Subject: [PATCH 1/5] feat: one-click deploy templates Add a curated template registry (compose templates) with a browse-and-deploy flow in the web UI: template detail, deploy dialog with project naming/env check, and a deployment detail page. Template stacks render into projects through the compose deploy path, with correct template ports (it-tools 80). --- apps/api/src/api/index.ts | 6 +- .../api/projects/__tests__/compose.test.ts | 59 +++ .../__tests__/templates-routes.test.ts | 194 +++++++ .../api/templates/__tests__/templates.test.ts | 110 ++++ apps/api/src/api/templates/index.ts | 209 ++++++++ apps/api/src/api/templates/registry.ts | 34 ++ apps/api/src/api/templates/templates/gitea.ts | 37 ++ .../src/api/templates/templates/it-tools.ts | 18 + apps/api/src/api/templates/templates/memos.ts | 22 + apps/api/src/api/templates/templates/n8n.ts | 35 ++ .../src/api/templates/templates/nextcloud.ts | 54 ++ .../api/templates/templates/paperless-ngx.ts | 102 ++++ .../src/api/templates/templates/plausible.ts | 80 +++ apps/api/src/api/templates/templates/umami.ts | 48 ++ .../api/templates/templates/uptime-kuma.ts | 22 + .../api/templates/templates/vaultwarden.ts | 39 ++ .../src/api/templates/templates/wordpress.ts | 46 ++ apps/api/src/api/templates/types.ts | 56 ++ apps/api/src/utils/zip-store.ts | 93 ++++ apps/docs/blume.config.ts | 1 + apps/docs/docs/templates.mdx | 50 ++ apps/web/src/api/client.ts | 36 ++ apps/web/src/components/layout/Header.tsx | 2 +- .../create/DeploymentTargetSection.tsx | 9 +- .../project/deployments/DeploymentsTab.tsx | 39 +- .../deployments/deployment-history.tsx | 115 +++- .../project/settings/BuildSettingsSection.tsx | 193 +++++++ .../settings/ComposeSettingsSection.tsx | 378 +++++++++++++ .../settings/GeneralSettingsSection.tsx | 183 +++++++ .../project/settings/ProjectSettingsTab.tsx | 499 +++--------------- .../src/components/templates/TemplateFlow.tsx | 297 +++++++++++ apps/web/src/components/templates/logos.ts | 53 ++ apps/web/src/routes/CreateProjectPage.tsx | 325 +++++++----- apps/web/src/routes/Dashboard.tsx | 2 +- apps/web/src/routes/DeploymentDetailPage.tsx | 351 ++++++++++++ apps/web/src/routes/ProjectDetail.tsx | 4 +- apps/web/src/routes/TemplatesPage.tsx | 38 ++ apps/web/src/routes/index.tsx | 22 + apps/web/src/types/index.ts | 26 + 39 files changed, 3292 insertions(+), 595 deletions(-) create mode 100644 apps/api/src/api/projects/__tests__/compose.test.ts create mode 100644 apps/api/src/api/templates/__tests__/templates-routes.test.ts create mode 100644 apps/api/src/api/templates/__tests__/templates.test.ts create mode 100644 apps/api/src/api/templates/index.ts create mode 100644 apps/api/src/api/templates/registry.ts create mode 100644 apps/api/src/api/templates/templates/gitea.ts create mode 100644 apps/api/src/api/templates/templates/it-tools.ts create mode 100644 apps/api/src/api/templates/templates/memos.ts create mode 100644 apps/api/src/api/templates/templates/n8n.ts create mode 100644 apps/api/src/api/templates/templates/nextcloud.ts create mode 100644 apps/api/src/api/templates/templates/paperless-ngx.ts create mode 100644 apps/api/src/api/templates/templates/plausible.ts create mode 100644 apps/api/src/api/templates/templates/umami.ts create mode 100644 apps/api/src/api/templates/templates/uptime-kuma.ts create mode 100644 apps/api/src/api/templates/templates/vaultwarden.ts create mode 100644 apps/api/src/api/templates/templates/wordpress.ts create mode 100644 apps/api/src/api/templates/types.ts create mode 100644 apps/api/src/utils/zip-store.ts create mode 100644 apps/docs/docs/templates.mdx create mode 100644 apps/web/src/components/project/settings/BuildSettingsSection.tsx create mode 100644 apps/web/src/components/project/settings/ComposeSettingsSection.tsx create mode 100644 apps/web/src/components/project/settings/GeneralSettingsSection.tsx create mode 100644 apps/web/src/components/templates/TemplateFlow.tsx create mode 100644 apps/web/src/components/templates/logos.ts create mode 100644 apps/web/src/routes/DeploymentDetailPage.tsx create mode 100644 apps/web/src/routes/TemplatesPage.tsx diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts index 7e052a5..6be2a8b 100644 --- a/apps/api/src/api/index.ts +++ b/apps/api/src/api/index.ts @@ -1,6 +1,5 @@ import { Elysia } from "elysia"; import { fixdiagRoutes } from "../fixdiag/routes"; -import { rateLimitMiddleware } from "./rate-limit"; import { agentRoutes } from "./agents"; import { alertsRoutes } from "./alerts"; import { apiKeysRoutes } from "./api-keys"; @@ -15,8 +14,10 @@ import { envVarsRoutes } from "./env-vars"; import { githubRoutes } from "./github"; import { healthRoutes } from "./health"; import { projectsRoutes } from "./projects"; +import { projectComposeRoutes } from "./projects/compose"; import { projectStatusRoutes } from "./projects/status"; import { prometheusRoutes } from "./prometheus"; +import { rateLimitMiddleware } from "./rate-limit"; import { fail } from "./response"; import { routesRoutes } from "./routes"; import { scalingRoutes } from "./scaling"; @@ -26,6 +27,7 @@ import { settingsRoutes } from "./settings"; import { llmSettingsRoutes } from "./settings/llm"; import { sharedEnvLinksRoutes, sharedEnvVarsRoutes } from "./shared-env-vars"; import { sshKeysRoutes } from "./ssh-keys"; +import { templateRoutes } from "./templates"; import { volumesRoutes } from "./volumes"; const INTERNAL_ERROR = @@ -62,7 +64,9 @@ export const apiRoutes = new Elysia({ .use(agentRoutes) .use(healthRoutes) .use(projectsRoutes) + .use(projectComposeRoutes) .use(projectStatusRoutes) + .use(templateRoutes) .use(deploymentsRoutes) .use(envVarsRoutes) .use(sharedEnvVarsRoutes) diff --git a/apps/api/src/api/projects/__tests__/compose.test.ts b/apps/api/src/api/projects/__tests__/compose.test.ts new file mode 100644 index 0000000..5ce3cb8 --- /dev/null +++ b/apps/api/src/api/projects/__tests__/compose.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { createZipStore } from "../../../utils/zip-store"; + +describe("project compose management", () => { + it("creates a zip store containing docker-compose.yml and unzips correctly", async () => { + const tempDir = mkdtempSync(join(process.cwd(), "scratch-compose-test-")); + try { + const composeContent = "services:\n app:\n image: nginx:alpine\n ports:\n - 80:80\n"; + const zipBytes = createZipStore([{ name: "docker-compose.yml", content: composeContent }]); + const zipPath = join(tempDir, "compose.zip"); + writeFileSync(zipPath, zipBytes); + + const { safeSpawn } = await import("../../../utils/process-exec"); + const res = await safeSpawn("unzip", ["-p", zipPath, "docker-compose.yml"]); + expect(res.code).toBe(0); + expect(res.stdout).toBe(composeContent.trim()); + + const parsed = parseYaml(res.stdout) as any; + expect(parsed.services.app.image).toBe("nginx:alpine"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("validates compose yaml structure", () => { + const invalidYaml = "services: [bad yaml"; + expect(() => parseYaml(invalidYaml)).toThrow(); + + const validYaml = "services:\n web:\n image: node:20-alpine\n ports:\n - 3000:3000\n"; + const parsed = parseYaml(validYaml) as any; + expect(parsed.services).toBeDefined(); + expect(parsed.services.web).toBeDefined(); + }); + + it("finds matching template for a project and recovers full compose yaml with version 3.8", async () => { + const { findTemplateForProject, getOrRecoverProjectCompose } = await import("../compose"); + const wpProject = { + id: "proj-wp-test", + name: "my-wordpress", + description: "Content management system (template: wordpress)", + composeService: "wordpress", + composePort: 80, + }; + const template = findTemplateForProject(wpProject); + expect(template).toBeDefined(); + expect(template?.key).toBe("wordpress"); + + const recovered = await getOrRecoverProjectCompose(wpProject); + expect(recovered).toContain('version: "3.8"'); + expect(recovered).toContain("services:"); + expect(recovered).toContain("wordpress:"); + expect(recovered).toContain("db:"); + expect(recovered).toContain("wp-content:"); + expect(recovered).toContain("wp-db:"); + }); +}); diff --git a/apps/api/src/api/templates/__tests__/templates-routes.test.ts b/apps/api/src/api/templates/__tests__/templates-routes.test.ts new file mode 100644 index 0000000..affc317 --- /dev/null +++ b/apps/api/src/api/templates/__tests__/templates-routes.test.ts @@ -0,0 +1,194 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { setDbProvider } from "../../../db/db-provider"; +import { createDeployment } from "../../../db/repo/deployments"; +import { + createEnvironmentVariable, + getEnvironmentVariablePlaintext, + listEnvironmentVariables, +} from "../../../db/repo/env-vars"; +import { createProject, listProjects } from "../../../db/repo/projects"; +import { createServer, ensureLocalServer, getServerById } from "../../../db/repo/servers"; +import * as schema from "../../../db/schema"; +import { createTestPool, truncateAllTables } from "../../../db/test-helper"; +import { createTemplateRoutes } from "../index"; + +let pool: Pool; +let workspaceRoot: string; +let routes: ReturnType; + +const repoOverrides = { + createProject, + createDeployment, + createEnvironmentVariable, + getServerById, + listProjects, +}; + +beforeAll(async () => { + pool = createTestPool(); + const db = drizzle(pool, { schema }); + setDbProvider(async () => db); + workspaceRoot = mkdtempSync(join(tmpdir(), "dequel-template-routes-")); + routes = createTemplateRoutes({ + repo: repoOverrides, + resolveServerId: async () => "local", + enqueue: () => {}, + workspaceRoot, + }); +}); + +beforeEach(async () => { + await truncateAllTables(pool); + await ensureLocalServer(); +}); + +afterAll(async () => { + await truncateAllTables(pool); + await pool.end(); + rmSync(workspaceRoot, { recursive: true, force: true }); +}); + +const postDeploy = (body: Record) => + routes.handle( + new Request("http://localhost/templates/n8n/deployments", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +describe("template routes", () => { + it("returns template metadata without raw compose", async () => { + const res = await routes.handle(new Request("http://localhost/templates")); + expect(res.status).toBe(200); + const json = (await res.json()) as { status: string; data: { key: string; service: string; port: number }[] }; + expect(json.status).toBe("success"); + expect(json.data.some((t) => t.key === "n8n")).toBe(true); + const entry = json.data.find((t) => t.key === "wordpress"); + expect(entry?.service).toBe("wordpress"); + expect(entry?.port).toBe(80); + }); + + it("creates a project, archive and deployment from a template", async () => { + const res = await postDeploy({ + name: "n8n-check", + values: { GENERIC_TIMEZONE: "Europe/Berlin" }, + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { + status: string; + data: { project: Record; deployment: Record }; + }; + expect(json.status).toBe("success"); + expect(json.data.project.buildType).toBe("compose"); + expect(json.data.project.composeService).toBe("n8n"); + expect(json.data.project.composePort).toBe(5678); + expect(json.data.project.sourceType).toBe("upload"); + expect(json.data.deployment.sourceType).toBe("upload"); + + const archivePath = json.data.deployment.sourceRef as string; + expect(existsSync(archivePath)).toBe(true); + + const out = join(workspaceRoot, "verify-extract"); + mkdirSync(out, { recursive: true }); + const proc = Bun.spawnSync(["unzip", "-q", archivePath, "-d", out]); + expect(proc.exitCode).toBe(0); + const compose = readFileSync(join(out, "docker-compose.yml"), "utf8"); + expect(compose).toContain(`GENERIC_TIMEZONE=\${GENERIC_TIMEZONE}`); + expect(compose).toContain(`N8N_ENCRYPTION_KEY=\${N8N_ENCRYPTION_KEY}`); + expect(compose).not.toMatch(/(? { + const res = await postDeploy({ name: "n8n-persist" }); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { project: { id: string }; deployment: { id: string } } }; + const projects = await listProjects(); + expect(projects.some((p) => p.id === json.data.project.id)).toBe(true); + }); + + it("stores the rendered variables as project env vars", async () => { + const res = await postDeploy({ name: "n8n-env", values: { GENERIC_TIMEZONE: "Europe/Berlin" } }); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { project: { id: string } } }; + const envs = await listEnvironmentVariables(json.data.project.id); + const keys = envs.map((v) => v.key); + expect(keys).toContain("GENERIC_TIMEZONE"); + expect(keys).toContain("N8N_ENCRYPTION_KEY"); + const secret = envs.find((v) => v.key === "N8N_ENCRYPTION_KEY"); + expect(secret).toBeDefined(); + const plain = await getEnvironmentVariablePlaintext(secret!.id); + expect(plain?.length).toBeGreaterThan(0); + }); + + it("rejects duplicate project names", async () => { + const first = await postDeploy({ name: "n8n-dup" }); + expect(first.status).toBe(200); + const second = await postDeploy({ name: "n8n-dup" }); + expect(second.status).toBe(400); + const json = (await second.json()) as { status: string; message: string }; + expect(json.status).toBe("error"); + }); + + it("rejects unknown template keys", async () => { + const res = await routes.handle( + new Request("http://localhost/templates/nope/deployments", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ); + expect(res.status).toBe(404); + }); + + it("rejects non-local servers", async () => { + const remoteRoutes = createTemplateRoutes({ + repo: { + createProject, + createDeployment, + getServerById: async () => ({ id: "srv", name: "srv", mode: "ssh" }) as never, + listProjects, + }, + resolveServerId: async () => "srv", + enqueue: () => {}, + workspaceRoot, + }); + const res = await remoteRoutes.handle( + new Request("http://localhost/templates/n8n/deployments", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "n8n-remote" }), + }), + ); + expect(res.status).toBe(400); + const json = (await res.json()) as { message: string }; + expect(json.message).toContain("remote servers support Git sources only"); + }); + + it("deploys to an explicitly selected local server", async () => { + const res = await postDeploy({ name: "n8n-pick", serverId: "local" }); + expect(res.status).toBe(200); + const json = (await res.json()) as { data: { project: { serverId: string | null } } }; + expect(json.data.project.serverId).toBe("local"); + }); + + it("rejects an explicitly selected non-local server", async () => { + const server = await createServer({ name: "edge-box", host: "203.0.113.9", mode: "ssh", sshUser: "deploy" }); + const res = await postDeploy({ name: "n8n-edge", serverId: server.id }); + expect(res.status).toBe(400); + const json = (await res.json()) as { message: string }; + expect(json.message).toContain("remote servers support Git sources only"); + }); + + it("rejects an unknown server id", async () => { + const res = await postDeploy({ name: "n8n-ghost", serverId: "no-such-server" }); + expect(res.status).toBe(400); + const json = (await res.json()) as { message: string }; + expect(json.message).toContain("does not exist"); + }); +}); diff --git a/apps/api/src/api/templates/__tests__/templates.test.ts b/apps/api/src/api/templates/__tests__/templates.test.ts new file mode 100644 index 0000000..f1a189c --- /dev/null +++ b/apps/api/src/api/templates/__tests__/templates.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { createZipStore } from "../../../utils/zip-store"; +import { getTemplate, listTemplates, renderCompose } from "../registry"; +import type { DeployTemplate } from "../types"; + +describe("deploy template registry", () => { + it("lists templates with unique keys, services and ports", () => { + const templates = listTemplates(); + expect(templates.length).toBeGreaterThanOrEqual(11); + expect(new Set(templates.map((t) => t.key)).size).toBe(templates.length); + for (const t of templates) { + expect(t.service.length).toBeGreaterThan(0); + expect(t.port).toBeGreaterThan(0); + expect(t.compose).toContain(t.service); + expect(t.vars.every((v) => /^[A-Z][A-Z0-9_]*$/.test(v.key))).toBe(true); + } + }); + + it("renders env references into the compose file", () => { + const template = getTemplate("wordpress"); + expect(template).toBeDefined(); + const { compose, vars } = renderCompose(template!, { + MYSQL_DATABASE: "wpdata", + MYSQL_USER: "wp-user", + MYSQL_ROOT_PASSWORD: "root-secret", + MYSQL_PASSWORD: "app-secret", + }); + expect(compose).toContain(`MYSQL_DATABASE=\${MYSQL_DATABASE}`); + expect(compose).toContain(`MYSQL_USER=\${MYSQL_USER}`); + expect(compose).toContain(`MYSQL_ROOT_PASSWORD=\${MYSQL_ROOT_PASSWORD}`); + expect(compose).toContain(`MYSQL_PASSWORD=\${MYSQL_PASSWORD}`); + expect(compose).not.toContain("wpdata"); + expect(vars.map((v) => [v.key, v.value])).toContainEqual(["MYSQL_DATABASE", "wpdata"]); + expect(compose).not.toMatch(/(? { + const template = getTemplate("n8n"); + expect(template).toBeDefined(); + const first = renderCompose(template!, {}); + expect(first.vars.find((v) => v.key === "N8N_ENCRYPTION_KEY")?.value.length).toBeGreaterThan(0); + const second = renderCompose(template!, {}); + expect(first.vars.find((v) => v.key === "N8N_ENCRYPTION_KEY")?.value).not.toEqual( + second.vars.find((v) => v.key === "N8N_ENCRYPTION_KEY")?.value, + ); + expect(first.compose).toContain(`N8N_ENCRYPTION_KEY=\${N8N_ENCRYPTION_KEY}`); + }); + + it("renders with defaults into parseable compose with the primary service", () => { + for (const template of listTemplates()) { + const { compose } = renderCompose(template, {}); + const doc = parseYaml(compose) as { services?: Record }; + expect(doc?.services).toBeDefined(); + expect(Object.keys(doc.services)).toContain(template.service); + } + }); + + it("uses provided values over defaults", () => { + const template = getTemplate("n8n")!; + const { compose, vars } = renderCompose(template, { GENERIC_TIMEZONE: "Europe/Berlin" }); + expect(vars.find((v) => v.key === "GENERIC_TIMEZONE")?.value).toBe("Europe/Berlin"); + expect(compose).toContain(`GENERIC_TIMEZONE=\${GENERIC_TIMEZONE}`); + }); + + it("rejects unresolved references and line breaks in values", () => { + const fake: DeployTemplate = { + key: "fake", + name: "fake", + description: "", + category: "", + service: "svc", + port: 80, + compose: "services:\n svc:\n image: alpine\n port: {MYSTERY_VAR}\n", + vars: [], + }; + expect(() => renderCompose(fake, {})).toThrow(/MYSTERY_VAR/); + + const oneVar: DeployTemplate = { ...fake, compose: "value: {ONLY_VAR}\n", vars: [{ key: "ONLY_VAR", label: "v" }] }; + expect(() => renderCompose(oneVar, { ONLY_VAR: "line\nbreak" })).toThrow(/line breaks/i); + }); +}); + +describe("zip store writer", () => { + it("produces an archive the platform extractor can read", () => { + const dir = mkdtempSync(join(tmpdir(), "dequel-zip-")); + try { + const compose = "services:\n svc:\n image: alpine\n"; + const zip = createZipStore([ + { name: "docker-compose.yml", content: compose }, + { name: "README.md", content: "hello world" }, + ]); + const zipPath = join(dir, "template.zip"); + writeFileSync(zipPath, zip); + const out = join(dir, "out"); + mkdirSync(out, { recursive: true }); + + const proc = Bun.spawnSync(["unzip", "-q", zipPath, "-d", out]); + expect(proc.exitCode).toBe(0); + + expect(readFileSync(join(out, "docker-compose.yml"), "utf8")).toBe(compose); + expect(readFileSync(join(out, "README.md"), "utf8")).toBe("hello world"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/api/src/api/templates/index.ts b/apps/api/src/api/templates/index.ts new file mode 100644 index 0000000..cea6a0a --- /dev/null +++ b/apps/api/src/api/templates/index.ts @@ -0,0 +1,209 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Elysia } from "elysia"; +import type { + CreateDeploymentInput, + CreateEnvironmentVariableInput, + CreateProjectInput, + Deployment, + Project, + Server, +} from "../../types"; +import { created, fail, ok } from "../response"; +import { getTemplate, listTemplates, type RenderedTemplate, renderCompose } from "./registry"; +import type { DeployTemplate } from "./types"; + +interface TemplateRepo { + createProject: (input: CreateProjectInput) => Promise; + createDeployment: (input: CreateDeploymentInput) => Promise; + createEnvironmentVariable: (input: CreateEnvironmentVariableInput) => Promise; + getServerById: (id: string) => Promise; + listProjects: () => Promise; + deleteProjectCascade?: (id: string) => Promise; +} + +export interface TemplateRouteOverrides { + repo?: TemplateRepo; + resolveServerId?: (preferred?: string | null, exclude?: string | null, modes?: string[]) => Promise; + enqueue?: (deploymentId: string) => void; + workspaceRoot?: string; +} + +type DeployBody = { + name?: unknown; + description?: unknown; + serverId?: unknown; + values?: unknown; +}; + +const isObjectType = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const templateMeta = (t: DeployTemplate) => ({ + key: t.key, + name: t.name, + description: t.description, + category: t.category, + docsUrl: t.docsUrl, + service: t.service, + port: t.port, + vars: t.vars.map((v) => ({ + key: v.key, + label: v.label, + description: v.description, + default: v.default, + secret: v.secret ?? false, + required: !v.default, + })), +}); + +export const createTemplateRoutes = (overrides?: TemplateRouteOverrides): Elysia => { + const loadRepo = async (): Promise => + overrides?.repo ?? ((await import("../../db/repo")) as any as TemplateRepo); + + const resolveServer = async ( + preferred?: string | null, + exclude?: string | null, + modes?: string[], + ): Promise => { + if (overrides?.resolveServerId) return overrides.resolveServerId(preferred, exclude, modes); + const { resolveDefaultServerId } = await import("../../utils/server-default"); + return resolveDefaultServerId(preferred, exclude, modes); + }; + + const enqueueDeployment = async (deploymentId: string): Promise => { + if (overrides?.enqueue) return overrides.enqueue(deploymentId); + const { orchestrator } = await import("../../orchestrator"); + orchestrator.enqueue(deploymentId); + }; + + const uploadsDir = async (): Promise => { + if (overrides?.workspaceRoot) return join(overrides.workspaceRoot, "uploads"); + const { config } = await import("../../utils/config"); + return join(config.workspaceRoot, "uploads"); + }; + + return new Elysia() + .get("/templates", () => ok(listTemplates().map(templateMeta))) + .get("/templates/:key", ({ params: { key }, set }) => { + const template = getTemplate(key); + if (!template) { + set.status = 404; + return fail(`Unknown template "${key}"`); + } + return ok(templateMeta(template)); + }) + .post("/templates/:key/deployments", async ({ params: { key }, body, set }: any) => { + const template = getTemplate(key); + if (!template) { + set.status = 404; + return fail(`Unknown template "${key}"`); + } + const input: DeployBody = isObjectType(body) ? (body as DeployBody) : {}; + const values: Record = isObjectType(input.values) ? (input.values as Record) : {}; + + let rendered: RenderedTemplate; + try { + rendered = renderCompose(template, values); + } catch (error) { + set.status = 400; + return fail(error instanceof Error ? error.message : "Failed to render template"); + } + + const name = String(input.name ?? template.name).trim(); + if (!name) { + set.status = 400; + return fail("Project name is required"); + } + + const repo = await loadRepo(); + const projects = await repo.listProjects(); + if (projects.some((p) => p.name === name)) { + set.status = 400; + return fail(`A project named "${name}" already exists`); + } + + const requestedServerId = + typeof input.serverId === "string" && input.serverId.trim() ? input.serverId.trim() : undefined; + let serverId: string | undefined; + if (requestedServerId) { + serverId = requestedServerId; + } else { + serverId = await resolveServer(undefined, undefined, ["local"]); + if (!serverId) { + set.status = 400; + return fail("No server available for template deployment"); + } + } + const server = await repo.getServerById(serverId); + if (!server) { + set.status = 400; + return fail("Selected deployment server does not exist"); + } + if (server.mode !== "local") { + set.status = 400; + return fail("Templates deploy Docker Compose stacks - remote servers support Git sources only right now"); + } + + const dir = await uploadsDir(); + mkdirSync(dir, { recursive: true }); + const archivePath = join(dir, `${Date.now()}-template-${template.key}.zip`); + const { createZipStore } = await import("../../utils/zip-store"); + writeFileSync(archivePath, createZipStore([{ name: "docker-compose.yml", content: rendered.compose }])); + + const project = await repo.createProject({ + name, + serverId, + description: + typeof input.description === "string" + ? input.description + : `${template.description} (template: ${template.key})`, + sourceType: "upload", + projectType: "web", + buildType: "compose", + composeService: template.service, + composePort: template.port, + }); + + try { + for (const v of rendered.vars) { + await repo.createEnvironmentVariable({ + projectId: project.id, + key: v.key, + value: v.value, + environment: "production", + }); + } + } catch (error) { + const cleanupFn = repo.deleteProjectCascade ?? (await import("../../db/repo")).deleteProjectCascade; + if (cleanupFn) { + await cleanupFn(project.id).catch(() => {}); + } + set.status = 500; + return fail(error instanceof Error ? error.message : "Failed to store template variables"); + } + + let deployment: Deployment; + try { + deployment = await repo.createDeployment({ + projectId: project.id, + serverId, + sourceType: "upload", + sourceRef: archivePath, + environment: "production", + }); + } catch (error) { + const cleanupFn = repo.deleteProjectCascade ?? (await import("../../db/repo")).deleteProjectCascade; + if (cleanupFn) { + await cleanupFn(project.id).catch(() => {}); + } + set.status = 500; + return fail(error instanceof Error ? error.message : "Failed to create deployment"); + } + + await enqueueDeployment(deployment.id); + return created({ project, deployment }); + }); +}; + +export const templateRoutes = createTemplateRoutes(); diff --git a/apps/api/src/api/templates/registry.ts b/apps/api/src/api/templates/registry.ts new file mode 100644 index 0000000..574bff8 --- /dev/null +++ b/apps/api/src/api/templates/registry.ts @@ -0,0 +1,34 @@ +import { giteaTemplate } from "./templates/gitea"; +import { itToolsTemplate } from "./templates/it-tools"; +import { memosTemplate } from "./templates/memos"; +import { n8nTemplate } from "./templates/n8n"; +import { nextcloudTemplate } from "./templates/nextcloud"; +import { paperlessNgxTemplate } from "./templates/paperless-ngx"; +import { plausibleTemplate } from "./templates/plausible"; +import { umamiTemplate } from "./templates/umami"; +import { uptimeKumaTemplate } from "./templates/uptime-kuma"; +import { vaultwardenTemplate } from "./templates/vaultwarden"; +import { wordPressTemplate } from "./templates/wordpress"; +import type { DeployTemplate } from "./types"; + +export type { DeployTemplate, RenderedTemplate, TemplateVar } from "./types"; +export { renderCompose } from "./types"; + +const templates: DeployTemplate[] = [ + n8nTemplate, + wordPressTemplate, + uptimeKumaTemplate, + giteaTemplate, + nextcloudTemplate, + paperlessNgxTemplate, + plausibleTemplate, + umamiTemplate, + vaultwardenTemplate, + memosTemplate, + itToolsTemplate, +]; + +export const listTemplates = (): DeployTemplate[] => templates; + +export const getTemplate = (key: string): DeployTemplate | undefined => + templates.find((t) => t.key.toLowerCase() === key.toLowerCase()); diff --git a/apps/api/src/api/templates/templates/gitea.ts b/apps/api/src/api/templates/templates/gitea.ts new file mode 100644 index 0000000..f97cc86 --- /dev/null +++ b/apps/api/src/api/templates/templates/gitea.ts @@ -0,0 +1,37 @@ +import type { DeployTemplate } from "../types"; + +export const giteaTemplate: DeployTemplate = { + key: "gitea", + name: "Gitea", + description: "Lightweight self-hosted Git service with issues, pulls and CI.", + category: "developer", + docsUrl: "https://docs.gitea.com/next/installation/install-with-docker", + service: "gitea", + port: 3000, + compose: `services: + gitea: + image: gitea/gitea:1 + pull_policy: always + restart: unless-stopped + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__server__DISABLE_SSH=true + - GITEA__server__HTTP_PORT=3000 + - GITEA__server__ROOT_URL={ROOT_URL} + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__database__PATH=/data/gitea/gitea.db + volumes: + - gitea-data:/data +volumes: + gitea-data: +`, + vars: [ + { + key: "ROOT_URL", + label: "Instance URL", + default: "http://localhost", + description: "Public URL of this Gitea instance, e.g. https://git.example.com. Used for links and SSH/web URLs.", + }, + ], +}; diff --git a/apps/api/src/api/templates/templates/it-tools.ts b/apps/api/src/api/templates/templates/it-tools.ts new file mode 100644 index 0000000..a755ee8 --- /dev/null +++ b/apps/api/src/api/templates/templates/it-tools.ts @@ -0,0 +1,18 @@ +import type { DeployTemplate } from "../types"; + +export const itToolsTemplate: DeployTemplate = { + key: "it-tools", + name: "IT Tools", + description: "Collection of handy online tools for developers, all in one place.", + category: "developer", + docsUrl: "https://github.com/CorentinTh/it-tools", + service: "it-tools", + port: 80, + compose: `services: + it-tools: + image: corentinth/it-tools:latest + pull_policy: always + restart: unless-stopped +`, + vars: [], +}; diff --git a/apps/api/src/api/templates/templates/memos.ts b/apps/api/src/api/templates/templates/memos.ts new file mode 100644 index 0000000..84c22b1 --- /dev/null +++ b/apps/api/src/api/templates/templates/memos.ts @@ -0,0 +1,22 @@ +import type { DeployTemplate } from "../types"; + +export const memosTemplate: DeployTemplate = { + key: "memos", + name: "Memos", + description: "Lightweight note-taking service, like a private stream of your thoughts.", + category: "productivity", + docsUrl: "https://github.com/usememos/memos", + service: "memos", + port: 5230, + compose: `services: + memos: + image: neosmemo/memos:stable + pull_policy: always + restart: unless-stopped + volumes: + - memos-data:/var/opt/memos +volumes: + memos-data: +`, + vars: [], +}; diff --git a/apps/api/src/api/templates/templates/n8n.ts b/apps/api/src/api/templates/templates/n8n.ts new file mode 100644 index 0000000..51c01f1 --- /dev/null +++ b/apps/api/src/api/templates/templates/n8n.ts @@ -0,0 +1,35 @@ +import type { DeployTemplate } from "../types"; + +export const n8nTemplate: DeployTemplate = { + key: "n8n", + name: "n8n", + description: "Workflow automation platform. Self-hosted alternative to Zapier.", + category: "automation", + docsUrl: "https://docs.n8n.io/hosting/", + service: "n8n", + port: 5678, + compose: `services: + n8n: + image: docker.n8n.io/n8nio/n8n:latest + pull_policy: always + restart: unless-stopped + environment: + - N8N_PORT=5678 + - N8N_PROTOCOL=http + - N8N_ENCRYPTION_KEY={N8N_ENCRYPTION_KEY} + - GENERIC_TIMEZONE={GENERIC_TIMEZONE} + volumes: + - n8n-data:/home/node/.n8n +volumes: + n8n-data: +`, + vars: [ + { + key: "N8N_ENCRYPTION_KEY", + label: "Encryption key", + description: "Used to encrypt credentials stored in n8n. Changing it after first run breaks stored credentials.", + secret: true, + }, + { key: "GENERIC_TIMEZONE", label: "Timezone", default: "UTC" }, + ], +}; diff --git a/apps/api/src/api/templates/templates/nextcloud.ts b/apps/api/src/api/templates/templates/nextcloud.ts new file mode 100644 index 0000000..0ef4ea6 --- /dev/null +++ b/apps/api/src/api/templates/templates/nextcloud.ts @@ -0,0 +1,54 @@ +import type { DeployTemplate } from "../types"; + +export const nextcloudTemplate: DeployTemplate = { + key: "nextcloud", + name: "Nextcloud", + description: "Files, calendar, contacts and collaboration, self-hosted in one place.", + category: "storage", + docsUrl: "https://docs.nextcloud.com/server/latest/admin_manual/installation/docker_install_config.html", + service: "nextcloud", + port: 80, + compose: `services: + db: + image: postgres:17-alpine + pull_policy: always + restart: unless-stopped + environment: + - POSTGRES_DB=nextcloud + - POSTGRES_USER=nextcloud + - POSTGRES_PASSWORD={NEXTCLOUD_DB_PASSWORD} + volumes: + - nextcloud-db:/var/lib/postgresql/data + nextcloud: + image: nextcloud:35-apache + pull_policy: always + restart: unless-stopped + depends_on: + - db + environment: + - POSTGRES_HOST=db + - POSTGRES_DB=nextcloud + - POSTGRES_USER=nextcloud + - POSTGRES_PASSWORD={NEXTCLOUD_DB_PASSWORD} + - NEXTCLOUD_TRUSTED_DOMAINS={TRUSTED_DOMAINS} + volumes: + - nextcloud-data:/var/www/html +volumes: + nextcloud-db: + nextcloud-data: +`, + vars: [ + { + key: "NEXTCLOUD_DB_PASSWORD", + label: "Database password", + secret: true, + }, + { + key: "TRUSTED_DOMAINS", + label: "Trusted domains", + default: "localhost", + description: + "Space-separated list of domains allowed to use this Nextcloud, e.g. mycloud.example.com. Include your Dequel domain here.", + }, + ], +}; diff --git a/apps/api/src/api/templates/templates/paperless-ngx.ts b/apps/api/src/api/templates/templates/paperless-ngx.ts new file mode 100644 index 0000000..8f7e851 --- /dev/null +++ b/apps/api/src/api/templates/templates/paperless-ngx.ts @@ -0,0 +1,102 @@ +import type { DeployTemplate } from "../types"; + +export const paperlessNgxTemplate: DeployTemplate = { + key: "paperless-ngx", + name: "Paperless-ngx", + description: "Document management with OCR, full-text search and automatic tagging.", + category: "productivity", + docsUrl: "https://docs.paperless-ngx.com/", + service: "webserver", + port: 8000, + compose: `services: + broker: + image: redis:7 + pull_policy: always + restart: unless-stopped + gotenberg: + image: gotenberg/gotenberg:8 + pull_policy: always + restart: unless-stopped + command: + - gotenberg + - --chromium-disable-routes=1 + tika: + image: apache/tika:latest + pull_policy: always + restart: unless-stopped + db: + image: postgres:16 + pull_policy: always + restart: unless-stopped + environment: + - POSTGRES_DB=paperless + - POSTGRES_USER=paperless + - POSTGRES_PASSWORD={PAPERLESS_DB_PASSWORD} + volumes: + - paperless-db:/var/lib/postgresql/data + webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:latest + pull_policy: always + restart: unless-stopped + depends_on: + - broker + - gotenberg + - tika + - db + environment: + - PAPERLESS_REDIS=redis://broker:6379 + - PAPERLESS_DBHOST=db + - PAPERLESS_DBNAME=paperless + - PAPERLESS_DBUSER=paperless + - PAPERLESS_DBPASS={PAPERLESS_DB_PASSWORD} + - PAPERLESS_SECRET_KEY={PAPERLESS_SECRET_KEY} + - PAPERLESS_TIME_ZONE={TIME_ZONE} + - PAPERLESS_TIKA_ENABLED=1 + - PAPERLESS_TIKA_ENDPOINT=http://tika:9998 + - PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://gotenberg:3000 + - PAPERLESS_ADMIN_USER={ADMIN_USER} + - PAPERLESS_ADMIN_PASSWORD={ADMIN_PASSWORD} + - PAPERLESS_CONSUMER_POLLING=30 + volumes: + - paperless-data:/usr/src/paperless/data + - paperless-media:/usr/src/paperless/media + - paperless-export:/usr/src/paperless/export + - paperless-consume:/usr/src/paperless/consume +volumes: + paperless-db: + paperless-data: + paperless-media: + paperless-export: + paperless-consume: +`, + vars: [ + { + key: "PAPERLESS_DB_PASSWORD", + label: "Database password", + secret: true, + }, + { + key: "PAPERLESS_SECRET_KEY", + label: "Secret key", + description: "Django secret used for session signing and password resets.", + secret: true, + }, + { + key: "ADMIN_USER", + label: "Admin username", + default: "admin", + }, + { + key: "ADMIN_PASSWORD", + label: "Admin password", + description: "Used to auto-create the first Paperless account on startup.", + secret: true, + }, + { + key: "TIME_ZONE", + label: "Timezone", + default: "UTC", + description: "IANA timezone, e.g. Europe/Berlin. Used for date display and document dating.", + }, + ], +}; diff --git a/apps/api/src/api/templates/templates/plausible.ts b/apps/api/src/api/templates/templates/plausible.ts new file mode 100644 index 0000000..cea3b88 --- /dev/null +++ b/apps/api/src/api/templates/templates/plausible.ts @@ -0,0 +1,80 @@ +import type { DeployTemplate } from "../types"; + +export const plausibleTemplate: DeployTemplate = { + key: "plausible", + name: "Plausible", + description: "Privacy-friendly web analytics, no cookies and GDPR compliant.", + category: "analytics", + docsUrl: "https://plausible.io/docs/self-hosting", + service: "plausible", + port: 8000, + compose: `services: + db: + image: postgres:16-alpine + pull_policy: always + restart: unless-stopped + environment: + - POSTGRES_USER=plausible + - POSTGRES_DB=plausible + - POSTGRES_PASSWORD={POSTGRES_PASSWORD} + volumes: + - plausible-db:/var/lib/postgresql/data + events-db: + image: clickhouse/clickhouse-server:24.12-alpine + pull_policy: always + restart: unless-stopped + environment: + - CLICKHOUSE_USER=plausible + - CLICKHOUSE_PASSWORD={CLICKHOUSE_PASSWORD} + - CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 + volumes: + - plausible-events:/var/lib/clickhouse + - plausible-events-user:/etc/clickhouse-users + - plausible-events-logs:/etc/clickhouse-server/server_logs + plausible: + image: ghcr.io/plausible/community-edition:latest + pull_policy: always + restart: unless-stopped + depends_on: + - db + - events-db + command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" + environment: + - BASE_URL={BASE_URL} + - SECRET_KEY_BASE={SECRET_KEY_BASE} + - DATABASE_URL=postgres://plausible:{POSTGRES_PASSWORD}@db:5432/plausible + - CLICKHOUSE_DATABASE_URL=http://events-db:8123/plausible_events_db + - CLICKHOUSE_DATABASE_USER=plausible + - CLICKHOUSE_DATABASE_PASSWORD={CLICKHOUSE_PASSWORD} +volumes: + plausible-db: + plausible-events: + plausible-events-user: + plausible-events-logs: +`, + vars: [ + { + key: "POSTGRES_PASSWORD", + label: "Database password", + secret: true, + }, + { + key: "CLICKHOUSE_PASSWORD", + label: "Events database password", + secret: true, + }, + { + key: "SECRET_KEY_BASE", + label: "Secret key", + description: "Server-side secret used to sign sessions and tokens.", + secret: true, + }, + { + key: "BASE_URL", + label: "Base URL", + default: "http://localhost", + description: + "Public URL of the Plausible dashboard, e.g. https://plausible.example.com. Update after deploy if your domain differs.", + }, + ], +}; diff --git a/apps/api/src/api/templates/templates/umami.ts b/apps/api/src/api/templates/templates/umami.ts new file mode 100644 index 0000000..802dcb2 --- /dev/null +++ b/apps/api/src/api/templates/templates/umami.ts @@ -0,0 +1,48 @@ +import type { DeployTemplate } from "../types"; + +export const umamiTemplate: DeployTemplate = { + key: "umami", + name: "Umami", + description: "Simple, privacy-focused web analytics with a clean dashboard.", + category: "analytics", + docsUrl: "https://umami.is/docs", + service: "umami", + port: 3000, + compose: `services: + db: + image: postgres:15-alpine + pull_policy: always + restart: unless-stopped + environment: + - POSTGRES_DB=umami + - POSTGRES_USER=umami + - POSTGRES_PASSWORD={UMAMI_DB_PASSWORD} + volumes: + - umami-db:/var/lib/postgresql/data + umami: + image: ghcr.io/umami-software/umami:latest + pull_policy: always + restart: unless-stopped + depends_on: + - db + environment: + - DATABASE_TYPE=postgresql + - DATABASE_URL=postgresql://umami:{UMAMI_DB_PASSWORD}@db:5432/umami + - APP_SECRET={APP_SECRET} +volumes: + umami-db: +`, + vars: [ + { + key: "UMAMI_DB_PASSWORD", + label: "Database password", + secret: true, + }, + { + key: "APP_SECRET", + label: "Session secret", + description: "Used by Umami to sign session cookies and tokens.", + secret: true, + }, + ], +}; diff --git a/apps/api/src/api/templates/templates/uptime-kuma.ts b/apps/api/src/api/templates/templates/uptime-kuma.ts new file mode 100644 index 0000000..730c4ac --- /dev/null +++ b/apps/api/src/api/templates/templates/uptime-kuma.ts @@ -0,0 +1,22 @@ +import type { DeployTemplate } from "../types"; + +export const uptimeKumaTemplate: DeployTemplate = { + key: "uptime-kuma", + name: "Uptime Kuma", + description: "Self-hosted monitoring tool with status pages and alerts.", + category: "monitoring", + docsUrl: "https://github.com/louislam/uptime-kuma", + service: "uptime-kuma", + port: 3001, + compose: `services: + uptime-kuma: + image: louislam/uptime-kuma:1 + pull_policy: always + restart: unless-stopped + volumes: + - uptime-kuma-data:/app/data +volumes: + uptime-kuma-data: +`, + vars: [], +}; diff --git a/apps/api/src/api/templates/templates/vaultwarden.ts b/apps/api/src/api/templates/templates/vaultwarden.ts new file mode 100644 index 0000000..a28020c --- /dev/null +++ b/apps/api/src/api/templates/templates/vaultwarden.ts @@ -0,0 +1,39 @@ +import type { DeployTemplate } from "../types"; + +export const vaultwardenTemplate: DeployTemplate = { + key: "vaultwarden", + name: "Vaultwarden", + description: "Lightweight password manager compatible with Bitwarden clients.", + category: "security", + docsUrl: "https://github.com/dani-garcia/vaultwarden", + service: "vaultwarden", + port: 80, + compose: `services: + vaultwarden: + image: vaultwarden/server:latest + pull_policy: always + restart: unless-stopped + environment: + - ADMIN_TOKEN={ADMIN_TOKEN} + - SIGNUPS_ALLOWED={SIGNUPS_ALLOWED} + volumes: + - vaultwarden-data:/data +volumes: + vaultwarden-data: +`, + vars: [ + { + key: "ADMIN_TOKEN", + label: "Admin panel token", + description: + "Token used to access the /admin panel. Keep it secret or flip SIGNUPS_ALLOWED down once accounts exist.", + secret: true, + }, + { + key: "SIGNUPS_ALLOWED", + label: "Allow public signups", + default: "true", + description: "Set to false to stop new account registrations once your accounts exist.", + }, + ], +}; diff --git a/apps/api/src/api/templates/templates/wordpress.ts b/apps/api/src/api/templates/templates/wordpress.ts new file mode 100644 index 0000000..f9055f3 --- /dev/null +++ b/apps/api/src/api/templates/templates/wordpress.ts @@ -0,0 +1,46 @@ +import type { DeployTemplate } from "../types"; + +export const wordPressTemplate: DeployTemplate = { + key: "wordpress", + name: "WordPress", + description: "Content management system with PHP and MySQL backend, bundled with MariaDB.", + category: "cms", + docsUrl: "https://hub.docker.com/_/wordpress", + service: "wordpress", + port: 80, + compose: `services: + db: + image: mariadb:11 + pull_policy: always + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD={MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE={MYSQL_DATABASE} + - MYSQL_USER={MYSQL_USER} + - MYSQL_PASSWORD={MYSQL_PASSWORD} + volumes: + - wp-db:/var/lib/mysql + wordpress: + image: wordpress:latest + pull_policy: always + restart: unless-stopped + depends_on: + - db + environment: + - WORDPRESS_DB_HOST=db + - WORDPRESS_DB_NAME={MYSQL_DATABASE} + - WORDPRESS_DB_USER={MYSQL_USER} + - WORDPRESS_DB_PASSWORD={MYSQL_PASSWORD} + volumes: + - wp-content:/var/www/html +volumes: + wp-db: + wp-content: +`, + vars: [ + { key: "MYSQL_DATABASE", label: "Database name", default: "wordpress" }, + { key: "MYSQL_USER", label: "Database user", default: "wordpress" }, + { key: "MYSQL_ROOT_PASSWORD", label: "MariaDB root password", secret: true }, + { key: "MYSQL_PASSWORD", label: "WordPress database password", secret: true }, + ], +}; diff --git a/apps/api/src/api/templates/types.ts b/apps/api/src/api/templates/types.ts new file mode 100644 index 0000000..4f29fb2 --- /dev/null +++ b/apps/api/src/api/templates/types.ts @@ -0,0 +1,56 @@ +import { randomBytes } from "node:crypto"; + +export interface TemplateVar { + key: string; + label: string; + description?: string; + default?: string; + secret?: boolean; +} + +export interface DeployTemplate { + key: string; + name: string; + description: string; + category: string; + docsUrl?: string; + service: string; + port: number; + compose: string; + vars: TemplateVar[]; +} + +export interface RenderedTemplate { + compose: string; + vars: { key: string; value: string; secret: boolean }[]; +} + +const generateSecret = (): string => randomBytes(24).toString("base64url"); + +export const renderCompose = (template: DeployTemplate, values: Record = {}): RenderedTemplate => { + const resolved: { key: string; value: string; secret: boolean }[] = []; + let result = template.compose.trim(); + if (!result.startsWith("version:")) { + result = `version: "3.8"\n\n${result}`; + } + + for (const v of template.vars) { + const provided = values[v.key]?.trim(); + const value = provided ? provided : v.default ? v.default : v.secret ? generateSecret() : undefined; + if (value === undefined) { + throw new Error(`Missing value for "${v.key}" (${v.label})`); + } + if (value.includes("\n") || value.includes("\r")) { + throw new Error(`Value for "${v.key}" must not contain line breaks`); + } + result = result.split(`{${v.key}}`).join(`\${${v.key}}`); + resolved.push({ key: v.key, value, secret: v.secret ?? false }); + } + + const leftover = result.match(/(? { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +const crc32 = (data: Uint8Array): number => { + let c = 0xffffffff; + for (let i = 0; i < data.length; i++) { + c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +}; + +export interface ZipEntry { + name: string; + content: string; +} + +export const createZipStore = (entries: ZipEntry[]): Uint8Array => { + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + const central: { name: Uint8Array; crc: number; size: number; offset: number }[] = []; + let offset = 0; + + for (const entry of entries) { + const name = encoder.encode(entry.name); + const data = encoder.encode(entry.content); + const crc = crc32(data); + const header = new Uint8Array(30 + name.length); + const view = new DataView(header.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0, true); + view.setUint16(8, 0, true); + view.setUint16(10, 0, true); + view.setUint16(12, 0x21, true); + view.setUint32(14, crc, true); + view.setUint32(18, data.length, true); + view.setUint32(22, data.length, true); + view.setUint16(26, name.length, true); + view.setUint16(28, 0, true); + header.set(name, 30); + chunks.push(header, data); + central.push({ name, crc, size: data.length, offset }); + offset += header.length + data.length; + } + + const centralStart = offset; + for (const entry of central) { + const dir = new Uint8Array(46 + entry.name.length); + const view = new DataView(dir.buffer); + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint16(8, 0, true); + view.setUint16(10, 0, true); + view.setUint16(12, 0, true); + view.setUint16(14, 0x21, true); + view.setUint32(16, entry.crc, true); + view.setUint32(20, entry.size, true); + view.setUint32(24, entry.size, true); + view.setUint16(28, entry.name.length, true); + view.setUint32(42, entry.offset, true); + dir.set(entry.name, 46); + chunks.push(dir); + offset += dir.length; + } + + const eocd = new Uint8Array(22); + const view = new DataView(eocd.buffer); + view.setUint32(0, 0x06054b50, true); + view.setUint16(8, central.length, true); + view.setUint16(10, central.length, true); + view.setUint32(12, offset - centralStart, true); + view.setUint32(16, centralStart, true); + chunks.push(eocd); + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const output = new Uint8Array(total); + let cursor = 0; + for (const chunk of chunks) { + output.set(chunk, cursor); + cursor += chunk.length; + } + return output; +}; diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts index 17204d0..4dac358 100644 --- a/apps/docs/blume.config.ts +++ b/apps/docs/blume.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ "/docs/configuration", "/docs/env-vars", "/docs/shared-env-vars", + "/docs/templates", "/docs/volumes", "/docs/domains", "/docs/scaling", diff --git a/apps/docs/docs/templates.mdx b/apps/docs/docs/templates.mdx new file mode 100644 index 0000000..cf8ff19 --- /dev/null +++ b/apps/docs/docs/templates.mdx @@ -0,0 +1,50 @@ +--- +title: One-Click Templates +category: Projects +description: The registry of one-click app templates Dequel can deploy with routing, SSL and secrets wired automatically. +slug: templates +--- + +One-click templates deploy popular self-hosted apps as complete Docker +Compose stacks - app plus its database, secrets, reverse-proxy route and +(via your `CADDY_BASE_DOMAIN`) HTTPS - in a single click. Open the +dashboard's **Create project** page and switch to **From a template**, or +go straight to `/templates` in the web app. + +Every template declares which settings it needs. Secrets you leave blank +are generated for you at deploy time; all values land in the project's +**Environment Variables** tab (encrypted at rest) and are injected into +the stack on deploy through compose interpolation. + +## Registry + +| Template | Category | What it deploys | +| --- | --- | --- | +| Gitea | developer | Lightweight self-hosted Git service with issues, pulls and CI | +| IT Tools | developer | Collection of handy developer tools, all in one place | +| Memos | productivity | Lightweight note-taking, like a private stream of your thoughts | +| n8n | automation | Workflow automation platform, self-hosted Zapier alternative | +| Nextcloud | storage | Files, calendar, contacts and collaboration | +| Paperless-ngx | productivity | Document management with OCR, full-text search and tagging | +| Plausible | analytics | Privacy-friendly, cookieless web analytics | +| Umami | analytics | Simple, privacy-focused analytics with a clean dashboard | +| Uptime Kuma | monitoring | Monitoring with status pages and alerts | +| Vaultwarden | security | Lightweight password manager compatible with Bitwarden clients | +| WordPress | cms | Content management system with PHP and a MariaDB backend | + +Each deployment gets a subdomain named after the project +(`.`), a status page under the project's +deployments tab, and its services joined to the private `dequel` network. + +## Managing template settings + +Template variables become regular environment variables of the project: + +1. Secrets left empty are generated once at deploy and stored encrypted. +2. Open the project's **Env** tab to view (masked) or edit values; use + the reveal action for secrets. +3. Compose files reference values as `${VAR}` placeholders, so a changed + value is applied on the next deploy/redeploy of the project. + +The stack's own compose file is rendered from the template at deploy +time - no secrets are baked into stored archives. diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 828d6c7..6c220ba 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -4,6 +4,7 @@ import type { ApiKey, CreateProjectInput, Deployment, + DeployTemplateMeta, DiagRun, DiagStage, Domain, @@ -16,6 +17,7 @@ import type { ScalingPolicy, Server, SmtpSettingsStatus, + TemplateDeployResult, Volume, } from "../types"; import { apiFetch, BASE } from "./http"; @@ -54,6 +56,28 @@ export const deleteProject = (id: string) => method: "DELETE", }); +export const getProjectCompose = (id: string) => + apiFetch<{ + compose: string; + composeService?: string | null; + composePort?: number | null; + composeServices?: any; + }>(`/projects/${id}/compose`); + +export const updateProjectCompose = ( + id: string, + data: { + compose: string; + composeService?: string; + composePort?: number; + composeServices?: string; + }, +) => + apiFetch<{ project: Project; deployment: Deployment }>(`/projects/${id}/compose`, { + method: "PUT", + body: JSON.stringify(data), + }); + // Deployments export const listDeployments = (projectId?: string, offset = 0, limit = 50) => { const params = new URLSearchParams(); @@ -73,6 +97,18 @@ export const createDeployment = (form: FormData) => method: "POST", body: form, }); + +// Templates +export const listTemplates = () => apiFetch("/templates"); +export const getTemplate = (key: string) => apiFetch(`/templates/${key}`); +export const deployTemplate = ( + key: string, + data: { name?: string; values?: Record; serverId?: string }, +) => + apiFetch(`/templates/${key}/deployments`, { + method: "POST", + body: JSON.stringify(data), + }); export const rollbackDeployment = (id: string) => apiFetch(`/deployments/${id}/rollback`, { method: "POST" }); export const redeployDeployment = (id: string) => diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 49ea2cc..2abe8b2 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -29,7 +29,7 @@ export function Header({ currentProject, currentProjectId, location, setSidebarO {currentProject.name} diff --git a/apps/web/src/components/project/create/DeploymentTargetSection.tsx b/apps/web/src/components/project/create/DeploymentTargetSection.tsx index 564fbfa..192c64b 100644 --- a/apps/web/src/components/project/create/DeploymentTargetSection.tsx +++ b/apps/web/src/components/project/create/DeploymentTargetSection.tsx @@ -1,4 +1,4 @@ -import { Laptop, Server } from "lucide-react"; +import { Laptop, Layers, Server } from "lucide-react"; import { cn } from "../../../lib/utils"; import type { Server as DequelServer } from "../../../types"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"; @@ -32,6 +32,13 @@ export function DeploymentTargetSelect({ serverId, setServerId, servers }: Deplo + + + + Auto + - picks the server with the most free resources + + {targets.map((s) => ( diff --git a/apps/web/src/components/project/deployments/DeploymentsTab.tsx b/apps/web/src/components/project/deployments/DeploymentsTab.tsx index b1f0fb7..0ff28f0 100644 --- a/apps/web/src/components/project/deployments/DeploymentsTab.tsx +++ b/apps/web/src/components/project/deployments/DeploymentsTab.tsx @@ -1,4 +1,5 @@ -import { Play, Rocket, Webhook } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; +import { Layers, Play, Rocket, Webhook } from "lucide-react"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import { getRepoHooks, registerRepoHook, removeRepoHook } from "../../../api/client"; @@ -26,6 +27,7 @@ interface DeploymentsTabProps { } export function DeploymentsTab({ projectId }: DeploymentsTabProps) { + const navigate = useNavigate(); const { data: project } = useProject(projectId); const [page, setPage] = useState(0); const { data, isLoading } = useDeployments(projectId, page, PAGE_SIZE); @@ -204,7 +206,40 @@ export function DeploymentsTab({ projectId }: DeploymentsTabProps) { )} - {sourceType === "git" ? ( + {project?.buildType === "compose" ? ( +
+
+
+
+
+ Docker Compose Stack +
+
+ Primary Service:{" "} + + {(project as any).composeService || "auto-detect"} + + {(project as any).composePort && ( + + (:{(project as any).composePort}) + + )} +
+
+ Update and manage services, ports, and docker-compose.yml configuration in Project Settings. +
+
+ +
+
+
+ ) : sourceType === "git" ? (
diff --git a/apps/web/src/components/project/deployments/deployment-history.tsx b/apps/web/src/components/project/deployments/deployment-history.tsx index 4c5e548..3fda6f1 100644 --- a/apps/web/src/components/project/deployments/deployment-history.tsx +++ b/apps/web/src/components/project/deployments/deployment-history.tsx @@ -1,4 +1,5 @@ -import { ChevronLeft, ChevronRight, History, RefreshCw } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; +import { ChevronLeft, ChevronRight, ExternalLink, History, RefreshCw } from "lucide-react"; import { useState } from "react"; import { cn } from "../../../lib/utils"; import { StatusBadge } from "../../StatusBadge"; @@ -37,6 +38,7 @@ export function DeploymentHistory({ onCancel, onDelete, }: DeploymentHistoryProps) { + const navigate = useNavigate(); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [cancelConfirmId, setCancelConfirmId] = useState(null); @@ -47,6 +49,8 @@ export function DeploymentHistory({
{deployments.map((dep) => ( + // biome-ignore lint/a11y/noStaticElementInteractions: selection card with nested interactive buttons + // biome-ignore lint/a11y/useKeyWithClickEvents: selection card with nested interactive buttons
onSelect(selectedId === dep.id ? null : dep.id)} @@ -64,13 +68,16 @@ export function DeploymentHistory({ )}
-
e.stopPropagation()}> +
{dep.status === "running" && dep.imageTag && dep.sourceType !== "image" && ( - )} - {dep.imageTag && dep.status !== "running" && dep.status !== "pending" && dep.status !== "building" && dep.status !== "deploying" && ( - )} + {dep.imageTag && + dep.status !== "running" && + dep.status !== "pending" && + dep.status !== "building" && + dep.status !== "deploying" && ( + + )} {dep.status !== "running" && ( )} +
@@ -116,15 +170,15 @@ export function DeploymentHistory({ {depDisplayName(projectName, dep.id)} - - {formatTimeAgo(dep.createdAt)} - + {formatTimeAgo(dep.createdAt)}
{dep.branch ? ( - {dep.branch} + + {dep.branch} + ) : null} {dep.sourceType}
@@ -217,6 +271,7 @@ export function DeploymentHistory({ strokeLinecap="round" strokeLinejoin="round" > + Cancel deployment @@ -260,11 +315,29 @@ export function DeploymentHistory({ strokeLinecap="round" strokeLinejoin="round" > + Delete deployment )} +
diff --git a/apps/web/src/components/project/settings/BuildSettingsSection.tsx b/apps/web/src/components/project/settings/BuildSettingsSection.tsx new file mode 100644 index 0000000..cd8510a --- /dev/null +++ b/apps/web/src/components/project/settings/BuildSettingsSection.tsx @@ -0,0 +1,193 @@ +import { Box, CheckCircle, Globe, Save } from "lucide-react"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { useUpdateProject } from "../../../hooks/useProjects"; +import { cn } from "../../../lib/utils"; +import type { Project } from "../../../types"; +import { Button } from "../../ui/button"; +import { Card, CardContent } from "../../ui/card"; +import { Input } from "../../ui/input"; + +interface BuildSettingsSectionProps { + project: Project; +} + +export function BuildSettingsSection({ project }: BuildSettingsSectionProps) { + const updateProjectMutation = useUpdateProject(); + + const [buildType, setBuildType] = useState("railpack"); + const [buildCommand, setBuildCommand] = useState(""); + const [installCommand, setInstallCommand] = useState(""); + const [outputDir, setOutputDir] = useState(""); + const [startCommand, setStartCommand] = useState(""); + const [saveSuccess, setSaveSuccess] = useState(false); + + useEffect(() => { + if (project) { + setBuildType((project as any).buildType || "railpack"); + setBuildCommand(project.buildCommand || ""); + setInstallCommand((project as any).installCommand || ""); + setOutputDir((project as any).outputDir || ""); + setStartCommand(project.startCommand || ""); + } + }, [project]); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await updateProjectMutation.mutateAsync({ + id: project.id, + buildType, + buildCommand: buildCommand.trim() || null, + installCommand: installCommand.trim() || null, + outputDir: outputDir.trim() || null, + startCommand: startCommand.trim() || null, + }); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 3000); + } catch (err) { + console.error("Failed to update build settings:", err); + } + }; + + return ( + + +
+
+
+

Build & Execution Strategy

+

Choose how Dequel compiles and launches your application.

+
+
+ {saveSuccess && ( +
+ + Strategy saved +
+ )} + +
+
+ +
+ Build Engine +
+ + +
+
+ + {buildType === "railpack" && ( +
+
+ + setInstallCommand(e.target.value)} + /> +

+ Override dependency installation. Leave blank for auto-detection. +

+
+ +
+ + setBuildCommand(e.target.value)} + /> +

+ Override build command. Leave blank for auto-detection. +

+
+ +
+ + setOutputDir(e.target.value)} + /> +

+ Directory containing compiled static assets (for Static Site projects). +

+
+ +
+ + setStartCommand(e.target.value)} + /> +

+ Override application launch command. Leave blank for auto-detection. +

+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/project/settings/ComposeSettingsSection.tsx b/apps/web/src/components/project/settings/ComposeSettingsSection.tsx new file mode 100644 index 0000000..edf455b --- /dev/null +++ b/apps/web/src/components/project/settings/ComposeSettingsSection.tsx @@ -0,0 +1,378 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { AlertCircle, CheckCircle2, Layers, Play, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import * as api from "../../../api/client"; +import type { Project } from "../../../types"; +import { Alert, AlertDescription } from "../../ui/alert"; +import { Badge } from "../../ui/badge"; +import { Button } from "../../ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../ui/card"; +import { Input } from "../../ui/input"; + +interface ComposeServiceRow { + id: string; + serviceName: string; + port: string; + subdomain: string; +} + +interface ComposeSettingsSectionProps { + project: Project; +} + +export function ComposeSettingsSection({ project }: ComposeSettingsSectionProps) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["project-compose", project.id], + queryFn: () => api.getProjectCompose(project.id), + }); + + const [composeCode, setComposeCode] = useState(""); + const [primaryService, setPrimaryService] = useState(""); + const [primaryPort, setPrimaryPort] = useState(""); + const [servicesList, setServicesList] = useState([]); + const [parseError, setParseError] = useState(null); + const [detectedServices, setDetectedServices] = useState([]); + const [saveSuccess, setSaveSuccess] = useState(false); + + const initializedFor = useRef(null); + useEffect(() => { + if (data && initializedFor.current !== project.id) { + initializedFor.current = project.id; + setComposeCode(data.compose || ""); + setPrimaryService(data.composeService || (project as any).composeService || ""); + setPrimaryPort( + data.composePort + ? String(data.composePort) + : (project as any).composePort + ? String((project as any).composePort) + : "", + ); + + const rawServices = data.composeServices ?? (project as any).composeServices; + if (rawServices) { + try { + const parsed = typeof rawServices === "string" ? JSON.parse(rawServices) : rawServices; + if (Array.isArray(parsed) && parsed.length > 0) { + setServicesList(parsed); + return; + } + } catch {} + } + setServicesList([ + { + id: "1", + serviceName: data.composeService || (project as any).composeService || "", + port: data.composePort ? String(data.composePort) : "", + subdomain: "", + }, + ]); + } + }, [data, project]); + + useEffect(() => { + if (!composeCode) return; + try { + const lines = composeCode.split("\n"); + let inServices = false; + const found: string[] = []; + for (const line of lines) { + const trimmed = line.trimEnd(); + if (/^services:\s*$/.test(trimmed)) { + inServices = true; + continue; + } + if (inServices) { + if (/^[a-zA-Z0-9_-]+:\s*$/.test(trimmed)) { + inServices = false; + continue; + } + const match = trimmed.match(/^ {2}([a-zA-Z0-9_-]+):\s*$/); + if (match?.[1]) { + found.push(match[1]); + } + } + } + setDetectedServices(found); + setParseError(null); + } catch (err) { + setParseError(err instanceof Error ? err.message : "Error parsing compose"); + } + }, [composeCode]); + + const updateMutation = useMutation({ + mutationFn: (payload: { + compose: string; + composeService?: string; + composePort?: number; + composeServices?: any[]; + }) => api.updateProjectCompose(project.id, payload), + onSuccess: (res) => { + setSaveSuccess(true); + queryClient.invalidateQueries({ queryKey: ["project-compose", project.id] }); + queryClient.invalidateQueries({ queryKey: ["project", project.id] }); + queryClient.invalidateQueries({ queryKey: ["deployments", project.id] }); + setTimeout(() => setSaveSuccess(false), 3000); + + if (res?.deployment?.id) { + navigate({ + to: "/project/$projectId/deployments/$deploymentId", + params: { projectId: project.id, deploymentId: res.deployment.id }, + }); + } + }, + }); + + const handleSave = () => { + const targetService = primaryService.trim() || detectedServices[0] || undefined; + const targetPort = primaryPort.trim() ? Number(primaryPort) : undefined; + updateMutation.mutate({ + compose: composeCode, + composeService: targetService, + composePort: targetPort, + composeServices: servicesList, + }); + }; + + const addRow = () => { + setServicesList((prev) => [...prev, { id: String(Date.now()), serviceName: "", port: "", subdomain: "" }]); + }; + + const removeRow = (id: string) => { + if (servicesList.length <= 1) return; + setServicesList((prev) => prev.filter((r) => r.id !== id)); + }; + + const updateRow = (id: string, field: keyof ComposeServiceRow, val: string) => { + setServicesList((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: val } : r))); + }; + + if (isLoading) { + return ( + + Loading compose stack configuration... + + ); + } + + if (error) { + return ( + + + Failed to load Docker Compose configuration + + ); + } + + return ( +
+ + +
+
+ + Docker Compose Stack + + + View and modify your stack definition. Saving will bundle the compose file and trigger a redeploy. + +
+
+ + +
+
+ + {updateMutation.error && ( + + + + {updateMutation.error instanceof Error + ? updateMutation.error.message + : "Failed to save and redeploy compose"} + + + )} + + {detectedServices.length > 0 && ( +
+ + Detected Services: + + {detectedServices.map((svc) => ( + + {svc} + + ))} +
+ )} +
+ + + {saveSuccess && ( + + + Compose configuration updated and deployment queued! + + )} + + {parseError && ( + + + {parseError} + + )} + +
+
+ + YAML syntax +
+