feat(project): unify Product+Hosting around code/images and live/previews

Anatomy + UI rewrite — locked the conceptual model after user feedback:

Product = "what makes up the thing you're shipping":
  - Codebases (Gitea repos)
  - Images (Coolify services backed by upstream Docker images: Twenty
    CRM, n8n, etc.)
  - Dev containers no longer surface here. The vibn-dev-* container is
    the AI's workshop, not a product surface; previews it serves still
    appear under Hosting → Previews.

Hosting = "where it lives + how it gets there", unified:
  - Live: every running endpoint as one list. Each item carries a
    source badge ("repo" | "image"), status dot, attached domain, and
    last-build summary inline. No separate Build, Domains or Services
    categories — those are properties on each Live item.
  - Previews: dev container preview URLs (unchanged).

Anatomy endpoint reshaped accordingly:
  - product.{codebases, images}
  - hosting.{live, previews}  (was production/services/previewUrls/domains)
  - lastBuild summary fetched per repo-app via listApplicationDeployments
    in parallel.

ProjectStagePill rewired to derive Live/Down/Building from hosting.live
+ hosting.previews. dev-container-detail.tsx removed.

services.* MCP tools added so AI agents can manage Coolify services
(Twenty CRM, n8n, …) the same way they manage apps:
  - services.list, services.get
  - services.start, services.stop
  - services.envs.list, services.envs.upsert
All tenant-scoped via getServiceInWorkspace + getOwnedCoolifyProjectUuids.
vibn-dev-* containers stay hidden from services.list.

Made-with: Cursor
This commit is contained in:
2026-04-28 19:36:35 -07:00
parent 3db7191146
commit 307c3ca858
7 changed files with 678 additions and 621 deletions

View File

@@ -79,6 +79,7 @@ import {
createDockerImageApp,
createDockerComposeApp,
startService,
stopService,
getService,
listAllServices,
listServiceEnvs,
@@ -392,6 +393,19 @@ export async function POST(request: Request) {
case 'ship':
return await toolShip(principal, params);
case 'services.list':
return await toolServicesList(principal, params);
case 'services.get':
return await toolServicesGet(principal, params);
case 'services.start':
return await toolServicesStart(principal, params);
case 'services.stop':
return await toolServicesStop(principal, params);
case 'services.envs.list':
return await toolServicesEnvsList(principal, params);
case 'services.envs.upsert':
return await toolServicesEnvsUpsert(principal, params);
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
@@ -1052,6 +1066,121 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
return NextResponse.json({ result: { ok: true, key } });
}
// ──────────────────────────────────────────────────
// services.* — Coolify Services (Twenty CRM, n8n, etc.)
//
// Services are upstream Docker images that Coolify pulls and runs as
// docker-compose stacks. Distinct from apps (which build from Git).
// All ops are tenant-scoped via the workspace's owned Coolify projects
// — agents from one workspace cannot read or mutate another's services.
// ──────────────────────────────────────────────────
async function toolServicesList(principal: Principal, params: Record<string, any> = {}) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
// Mirror apps.list scoping: optional `projectId` narrows to a single
// Vibn project's Coolify env; otherwise scan everything we own.
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
let target: string[];
if (params.projectId) {
const pUuid = await getProjectCoolifyUuid(String(params.projectId), principal.workspace);
if (!pUuid) return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 });
target = [pUuid];
} else {
target = Array.from(ownedUuids);
if (target.length === 0 && principal.workspace.coolify_project_uuid) {
target = [principal.workspace.coolify_project_uuid];
}
}
if (target.length === 0) return NextResponse.json({ result: [] });
const results = await Promise.allSettled(target.map(uuid => listServicesInProject(uuid)));
const services = results.flatMap((r, i) =>
r.status === 'fulfilled'
? r.value
// Hide vibn-dev-* dev containers from this surface — those are
// the AI's own workshop, not part of the user's product.
.filter(s => !s.name.startsWith('vibn-dev-'))
.map(s => ({
uuid: s.uuid,
name: s.name,
status: s.status ?? 'unknown',
serviceType: s.service_type ?? null,
coolifyProjectUuid: target[i],
}))
: []
);
return NextResponse.json({ result: services });
}
async function toolServicesGet(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const svc = await getServiceInWorkspace(uuid, ownedUuids);
return NextResponse.json({ result: svc });
}
async function toolServicesStart(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
await startService(uuid);
return NextResponse.json({ result: { ok: true, uuid, action: 'start' } });
}
async function toolServicesStop(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
await stopService(uuid);
return NextResponse.json({ result: { ok: true, uuid, action: 'stop' } });
}
async function toolServicesEnvsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
const envs = await listServiceEnvs(uuid);
return NextResponse.json({ result: envs });
}
async function toolServicesEnvsUpsert(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const uuid = String(params.uuid ?? '').trim();
const key = typeof params.key === 'string' ? params.key : '';
const value = typeof params.value === 'string' ? params.value : '';
if (!uuid || !key) {
return NextResponse.json({ error: 'Params "uuid" and "key" are required' }, { status: 400 });
}
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
await getServiceInWorkspace(uuid, ownedUuids);
const result = await upsertServiceEnv(uuid, {
key,
value,
is_preview: !!params.is_preview,
is_literal: !!params.is_literal,
});
return NextResponse.json({ result });
}
// ──────────────────────────────────────────────────
// Phase 4: apps create/update/delete + domains
// ──────────────────────────────────────────────────

View File

@@ -1,13 +1,26 @@
/**
* GET /api/projects/[projectId]/anatomy
*
* Returns the full anatomy of a project across the three tabs:
* - codebases: discovered from Gitea (apps/* or repo root)
* - hosting: production apps + dev services + preview URLs + domains
* - infrastructure: TODO (returns placeholder shape for now)
* Single-fetch shape consumed by the Product / Hosting / Infrastructure
* tabs. Keeping it one endpoint keeps page transitions cheap and avoids
* fan-out.
*
* Single endpoint per page so the UI doesn't fan out 3+ requests on
* every navigation. Each tab consumes its own slice.
* Conceptual model (locked Apr 28 2026):
* Product = "what makes up the thing you're shipping"
* → codebases (Gitea repos) + images (Coolify services
* backed by an upstream Docker image, e.g. Twenty CRM,
* n8n). Both are first-class product surfaces.
* → vibn-dev-* containers are NOT shown — the dev
* container is the AI's workshop, not the product.
*
* Hosting = "where does it live and how does it get there"
* → unified `live` list of running endpoints (each item
* carries source = "repo" | "image", attached domains,
* and last build/deploy status inline) + `previews`
* (dev container preview URLs).
* → no separate Build, Domains, or Services categories.
*
* Infrastructure = TODO (placeholder).
*/
import { NextResponse } from "next/server";
@@ -15,6 +28,7 @@ import { authSession } from "@/lib/auth/session-server";
import { query } from "@/lib/db-postgres";
import {
listApplications,
listApplicationDeployments,
listServicesInProject,
type CoolifyApplication,
type CoolifyService,
@@ -34,30 +48,43 @@ interface Codebase {
hint?: string;
}
interface ProductionApp {
interface ProductImage {
uuid: string;
name: string;
/** "twentycrm/twenty" */
image: string;
/** "v1.15" — empty string when not pinned */
version: string;
serviceType?: string;
/** Coolify service status, surfaced so the Product tile can show a dot */
status?: string;
}
interface BuildSummary {
status: string;
finishedAt?: string;
commit?: string;
}
interface LiveEndpoint {
uuid: string;
name: string;
/** repo = built from Gitea, image = pulled docker image (Coolify service) */
source: "repo" | "image";
/** "apps/web" or "twentycrm/twenty:v1.15" */
sourceLabel: string;
status: string;
/** primary host (no scheme) when one exists */
fqdn?: string;
/** all attached hosts */
domains: string[];
branch?: string;
buildPack?: string;
/** Last finished deployment, only for source = "repo" */
lastBuild?: BuildSummary;
}
interface DevService {
uuid: string;
name: string;
serviceType?: string;
status?: string;
}
/** Dev container = the vibn-dev-* Coolify service this project edits in. */
interface DevContainer {
uuid: string;
name: string;
status?: string;
}
interface PreviewUrl {
interface Preview {
id: string;
name: string;
port: number;
@@ -66,32 +93,24 @@ interface PreviewUrl {
startedAt: string;
}
interface Domain {
host: string;
source: "production" | "preview";
}
interface Anatomy {
project: { id: string; name: string; gitea?: string; coolifyProjectUuid?: string };
codebases: Codebase[];
codebasesReason?: "no_repo" | "empty_repo";
product: {
devContainers: DevContainer[];
codebases: Codebase[];
images: ProductImage[];
};
hosting: {
production: ProductionApp[];
services: DevService[];
previewUrls: PreviewUrl[];
domains: Domain[];
live: LiveEndpoint[];
previews: Preview[];
};
infrastructure: {
/** TODO Phase 4 — see PROJECT_PAGE_ARCHITECTURE.md for the design call. */
placeholder: true;
};
}
// ──────────────────────────────────────────────────
// Gitea
// Gitea (codebase discovery)
// ──────────────────────────────────────────────────
interface GiteaItem {
@@ -150,20 +169,17 @@ async function discoverCodebases(giteaRepo: string): Promise<{
}
// ──────────────────────────────────────────────────
// Hosting — Coolify + fs_dev_servers
// Coolify helpers
// ──────────────────────────────────────────────────
/** Strip credentials + .git suffix and normalise to lowercase */
function normaliseRepoUrl(url: string | undefined): string {
if (!url) return "";
let u = url.toLowerCase();
// Remove user:pass@ if present
u = u.replace(/^https?:\/\/[^/@]*@/, "https://");
u = u.replace(/\.git$/, "");
return u;
}
/** Returns the canonical short form: "owner/repo" */
function shortFormOfRepo(url: string | undefined): string {
if (!url) return "";
const cleaned = normaliseRepoUrl(url).replace(/^https?:\/\/[^/]+\//, "");
@@ -174,33 +190,21 @@ function appMatchesRepo(app: CoolifyApplication, giteaRepo: string): boolean {
const target = giteaRepo.toLowerCase();
const appShort = shortFormOfRepo(app.git_repository);
if (appShort && appShort === target) return true;
// Also match if either side contains the other (loose fallback for legacy data)
return Boolean(app.git_repository && app.git_repository.toLowerCase().includes(target));
}
async function loadProductionApps(giteaRepo: string | undefined): Promise<ProductionApp[]> {
async function loadRepoApps(giteaRepo: string | undefined): Promise<CoolifyApplication[]> {
if (!giteaRepo) return [];
try {
const all = await listApplications();
return all
.filter(app => appMatchesRepo(app, giteaRepo))
.map(app => ({
uuid: app.uuid,
name: app.name,
status: app.status,
fqdn: app.fqdn,
branch: app.git_branch,
buildPack: app.build_pack,
}));
return all.filter(app => appMatchesRepo(app, giteaRepo));
} catch (err) {
console.error("[anatomy] listApplications failed:", err);
return [];
}
}
/** Returns ALL services in the Coolify project. Caller splits dev
* containers from deployed services by name prefix. */
async function loadAllServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
async function loadProjectServices(coolifyProjectUuid: string | undefined): Promise<CoolifyService[]> {
if (!coolifyProjectUuid) return [];
try {
return await listServicesInProject(coolifyProjectUuid);
@@ -210,11 +214,48 @@ async function loadAllServices(coolifyProjectUuid: string | undefined): Promise<
}
}
function isDevContainer(svc: CoolifyService): boolean {
return svc.name.startsWith("vibn-dev-");
const isDevContainer = (svc: CoolifyService) => svc.name.startsWith("vibn-dev-");
/** Extract image:version from a Coolify docker_compose_raw blob.
* Best-effort regex; we only want a sensible label, not perfection. */
function extractImageInfo(svc: CoolifyService): { image: string; version: string } {
const raw = (svc as unknown as { docker_compose_raw?: string }).docker_compose_raw ?? "";
const m = raw.match(/image:\s*['"]?([^\s'"\n]+)['"]?/);
if (!m) return { image: svc.service_type ?? svc.name, version: "" };
const full = m[1];
const at = full.lastIndexOf(":");
if (at <= 0 || full.slice(at).includes("/")) {
return { image: full, version: "" };
}
return { image: full.slice(0, at), version: full.slice(at + 1) };
}
async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
function fqdnsOf(value: string | undefined): string[] {
if (!value) return [];
return value
.split(",")
.map(s => s.trim().replace(/^https?:\/\//, "").replace(/\/$/, ""))
.filter(Boolean);
}
async function lastBuildFor(uuid: string): Promise<BuildSummary | undefined> {
try {
const deployments = await listApplicationDeployments(uuid);
if (!deployments.length) return undefined;
// Prefer the most recently finished; fall back to first.
const finished = deployments.find(d => d.finished_at) ?? deployments[0];
return {
status: finished.status,
finishedAt: finished.finished_at,
commit: finished.commit,
};
} catch (err) {
console.error(`[anatomy] listApplicationDeployments(${uuid}) failed:`, err);
return undefined;
}
}
async function loadPreviews(projectId: string): Promise<Preview[]> {
try {
const rows = await query<{
id: string;
@@ -239,7 +280,6 @@ async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
startedAt: r.started_at,
}));
} catch (err) {
// fs_dev_servers may not exist yet on older deployments — treat as empty
if (err instanceof Error && /relation "fs_dev_servers" does not exist/i.test(err.message)) {
return [];
}
@@ -248,25 +288,6 @@ async function loadPreviewUrls(projectId: string): Promise<PreviewUrl[]> {
}
}
function dedupeDomains(prod: ProductionApp[], previews: PreviewUrl[]): Domain[] {
const map = new Map<string, Domain>();
for (const app of prod) {
if (!app.fqdn) continue;
// fqdn can be a comma-separated list
for (const raw of app.fqdn.split(",")) {
const host = raw.trim().replace(/^https?:\/\//, "").replace(/\/$/, "");
if (host && !map.has(host)) map.set(host, { host, source: "production" });
}
}
for (const p of previews) {
try {
const host = new URL(p.url).host;
if (host && !map.has(host)) map.set(host, { host, source: "preview" });
} catch { /* malformed URL, skip */ }
}
return [...map.values()];
}
// ──────────────────────────────────────────────────
// Handler
// ──────────────────────────────────────────────────
@@ -300,55 +321,80 @@ export async function GET(
(data?.name as string | undefined) ??
"Project";
// Run the slow bits in parallel
const [codebasesResult, production, allServices, previews] = await Promise.all([
const [codebasesResult, repoApps, allServices, previews] = await Promise.all([
giteaRepo
? discoverCodebases(giteaRepo).catch(err => {
console.error("[anatomy] discoverCodebases failed:", err);
return { codebases: [] as Codebase[], reason: "empty_repo" as const };
})
: Promise.resolve({ codebases: [] as Codebase[], reason: undefined as undefined }),
loadProductionApps(giteaRepo),
loadAllServices(coolifyProjectUuid),
loadPreviewUrls(projectId),
loadRepoApps(giteaRepo),
loadProjectServices(coolifyProjectUuid),
loadPreviews(projectId),
]);
// Split services: vibn-dev-* belong to Product (the dev workbench).
// Everything else is a deployed service that belongs in Hosting.
const devContainers: DevContainer[] = [];
const deployedServices: DevService[] = [];
for (const s of allServices) {
if (isDevContainer(s)) {
devContainers.push({ uuid: s.uuid, name: s.name, status: s.status });
} else {
deployedServices.push({
uuid: s.uuid,
name: s.name,
serviceType: s.service_type,
status: s.status,
});
}
}
// Pull last-build summaries for repo apps in parallel (small N).
const builds = await Promise.all(repoApps.map(a => lastBuildFor(a.uuid)));
// Image services (Coolify services minus vibn-dev-*)
const imageServices = allServices.filter(s => !isDevContainer(s));
const productImages: ProductImage[] = imageServices.map(s => {
const { image, version } = extractImageInfo(s);
return {
uuid: s.uuid,
name: s.name,
image,
version,
serviceType: s.service_type,
status: s.status,
};
});
const liveFromRepo: LiveEndpoint[] = repoApps.map((app, i) => {
const domains = fqdnsOf(app.fqdn);
return {
uuid: app.uuid,
name: app.name,
source: "repo",
sourceLabel: shortFormOfRepo(app.git_repository) || (giteaRepo ?? "repo"),
status: app.status,
fqdn: domains[0],
domains,
branch: app.git_branch,
buildPack: app.build_pack,
lastBuild: builds[i],
};
});
const liveFromImage: LiveEndpoint[] = imageServices.map(s => {
const domains = fqdnsOf((s as unknown as { fqdn?: string }).fqdn);
const { image, version } = extractImageInfo(s);
return {
uuid: s.uuid,
name: s.name,
source: "image",
sourceLabel: version ? `${image}:${version}` : image,
status: s.status ?? "unknown",
fqdn: domains[0],
domains,
};
});
const codebasesReason: "no_repo" | "empty_repo" | undefined = !giteaRepo
? "no_repo"
: codebasesResult.reason;
const anatomy: Anatomy = {
project: {
id: projectId,
name: projectName,
gitea: giteaRepo,
coolifyProjectUuid,
},
codebases: codebasesResult.codebases,
project: { id: projectId, name: projectName, gitea: giteaRepo, coolifyProjectUuid },
codebasesReason,
product: { devContainers },
product: {
codebases: codebasesResult.codebases,
images: productImages,
},
hosting: {
production,
services: deployedServices,
previewUrls: previews,
domains: dedupeDomains(production, previews),
live: [...liveFromRepo, ...liveFromImage],
previews,
},
infrastructure: { placeholder: true },
};