From 3b70eb1aa7b0f2fa946555bd597037093c1caed7 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:08:15 +0000 Subject: [PATCH 01/15] feat(schema): add git integration fields to Environment model --- .../20260306000000_add_environment_git_config/migration.sql | 4 ++++ prisma/schema.prisma | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 prisma/migrations/20260306000000_add_environment_git_config/migration.sql diff --git a/prisma/migrations/20260306000000_add_environment_git_config/migration.sql b/prisma/migrations/20260306000000_add_environment_git_config/migration.sql new file mode 100644 index 00000000..bee2f8fc --- /dev/null +++ b/prisma/migrations/20260306000000_add_environment_git_config/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "gitRepoUrl" TEXT, +ADD COLUMN "gitBranch" TEXT DEFAULT 'main', +ADD COLUMN "gitToken" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85011e5a..d8bef2f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,9 @@ model Environment { enrollmentTokenHint String? secretBackend SecretBackend @default(BUILTIN) secretBackendConfig Json? + gitRepoUrl String? + gitBranch String? @default("main") + gitToken String? // Stored encrypted via crypto.ts alertRules AlertRule[] alertWebhooks AlertWebhook[] createdAt DateTime @default(now()) From 9ea502319d11cbf6259d6906dac0f8acfa0054a7 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:09:24 +0000 Subject: [PATCH 02/15] chore: add simple-git dependency for pipeline audit trail --- package.json | 1 + pnpm-lock.yaml | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/package.json b/package.json index 772a804c..5cb628ba 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "react-dom": "19.2.3", "react-hook-form": "^7.71.2", "recharts": "2.15.4", + "simple-git": "^3.32.3", "sonner": "^2.0.7", "superjson": "^2.2.6", "tailwind-merge": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6699f116..e42fde60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + simple-git: + specifier: ^3.32.3 + version: 3.32.3 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -824,6 +827,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + + '@kwsites/promise-deferred@1.1.1': + resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@modelcontextprotocol/sdk@1.27.1': resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} engines: {node: '>=18'} @@ -4319,6 +4328,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-git@3.32.3: + resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -5383,6 +5395,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@kwsites/promise-deferred@1.1.1': {} + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.4) @@ -9204,6 +9224,14 @@ snapshots: signal-exit@4.1.0: {} + simple-git@3.32.3: + dependencies: + '@kwsites/file-exists': 1.1.1 + '@kwsites/promise-deferred': 1.1.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + sisteransi@1.0.5: {} sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): From a72083deb8fed149a875ea1453515133881f2534 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:11:13 +0000 Subject: [PATCH 03/15] feat: add git sync service for pipeline audit trail Implements the core git-sync service that handles cloning, committing, and pushing pipeline YAML files to a configured Git repository. Supports both commit (deploy) and delete operations with temp directory cleanup and non-throwing error handling. --- src/server/services/git-sync.ts | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/server/services/git-sync.ts diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts new file mode 100644 index 00000000..d6aca781 --- /dev/null +++ b/src/server/services/git-sync.ts @@ -0,0 +1,144 @@ +import simpleGit, { SimpleGit } from "simple-git"; +import { mkdtemp, writeFile, rm, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { decrypt } from "@/server/services/crypto"; + +export interface GitSyncConfig { + repoUrl: string; + branch: string; + encryptedToken: string; +} + +export interface GitSyncResult { + success: boolean; + commitSha?: string; + error?: string; +} + +interface GitAuthor { + name: string; + email: string; +} + +/** + * Build an authenticated HTTPS URL by injecting the PAT. + * Supports GitHub, GitLab, Bitbucket URL formats. + * Example: https://github.com/org/repo.git → https://@github.com/org/repo.git + */ +function authenticatedUrl(repoUrl: string, token: string): string { + const url = new URL(repoUrl); + url.username = token; + url.password = ""; + return url.toString(); +} + +/** + * Slugify a string for use as a filename. + */ +export function toFilenameSlug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Commit a pipeline YAML file to the configured Git repo. + * Used after successful deploy. + */ +export async function gitSyncCommitPipeline( + config: GitSyncConfig, + environmentName: string, + pipelineName: string, + configYaml: string, + author: GitAuthor, + commitMessage: string, +): Promise { + let workdir: string | null = null; + + try { + const token = decrypt(config.encryptedToken); + const url = authenticatedUrl(config.repoUrl, token); + workdir = await mkdtemp(join(tmpdir(), "vf-git-sync-")); + + const git: SimpleGit = simpleGit(workdir); + await git.clone(url, workdir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + + // Write the pipeline YAML file + const envDir = toFilenameSlug(environmentName); + const filename = `${toFilenameSlug(pipelineName)}.yaml`; + const filePath = join(envDir, filename); + const fullPath = join(workdir, filePath); + + await mkdir(join(workdir, envDir), { recursive: true }); + await writeFile(fullPath, configYaml, "utf-8"); + + await git.add(filePath); + + // Check if there are actually changes to commit + const status = await git.status(); + if (status.isClean()) { + return { success: true, commitSha: "no-change" }; + } + + await git.commit(commitMessage, filePath, { + "--author": `${author.name || "VectorFlow User"} <${author.email}>`, + }); + await git.push("origin", config.branch); + + const log = await git.log({ maxCount: 1 }); + return { success: true, commitSha: log.latest?.hash }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[git-sync] Commit failed:", message); + return { success: false, error: message }; + } finally { + if (workdir) { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +/** + * Delete a pipeline YAML file from the configured Git repo. + * Used after pipeline deletion. + */ +export async function gitSyncDeletePipeline( + config: GitSyncConfig, + environmentName: string, + pipelineName: string, + author: GitAuthor, +): Promise { + let workdir: string | null = null; + + try { + const token = decrypt(config.encryptedToken); + const url = authenticatedUrl(config.repoUrl, token); + workdir = await mkdtemp(join(tmpdir(), "vf-git-sync-")); + + const git: SimpleGit = simpleGit(workdir); + await git.clone(url, workdir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + + const envDir = toFilenameSlug(environmentName); + const filename = `${toFilenameSlug(pipelineName)}.yaml`; + const filePath = join(envDir, filename); + + await git.rm(filePath); + await git.commit(`Delete pipeline: ${pipelineName}`, filePath, { + "--author": `${author.name || "VectorFlow User"} <${author.email}>`, + }); + await git.push("origin", config.branch); + + const log = await git.log({ maxCount: 1 }); + return { success: true, commitSha: log.latest?.hash }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[git-sync] Delete failed:", message); + return { success: false, error: message }; + } finally { + if (workdir) { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + } +} From fd9ec14127556438799798db56615964676b9eab Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:13:01 +0000 Subject: [PATCH 04/15] feat: wire git sync into deploy agent flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call gitSyncCommitPipeline after version creation in deployAgent. The sync runs as a non-blocking side effect — if it fails, the deploy still succeeds and the error is surfaced via gitSyncError in the result. --- src/server/services/deploy-agent.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/server/services/deploy-agent.ts b/src/server/services/deploy-agent.ts index f81bc013..c2f8587c 100644 --- a/src/server/services/deploy-agent.ts +++ b/src/server/services/deploy-agent.ts @@ -5,6 +5,7 @@ import { validateConfig } from "@/server/services/validator"; import { createVersion } from "@/server/services/pipeline-version"; import { decryptNodeConfig } from "@/server/services/config-crypto"; import { startSystemVector, stopSystemVector } from "@/server/services/system-vector"; +import { gitSyncCommitPipeline } from "@/server/services/git-sync"; export interface AgentDeployResult { success: boolean; @@ -12,6 +13,7 @@ export interface AgentDeployResult { versionId?: string; versionNumber?: number; validationErrors?: Array<{ message: string; componentKey?: string }>; + gitSyncError?: string; } /** @@ -87,6 +89,30 @@ export async function deployAgent( gc, ); + // 3b. Git sync (non-blocking side effect) + let gitSyncError: string | undefined; + const environment = await prisma.environment.findUnique({ + where: { id: pipeline.environmentId }, + }); + if (environment?.gitRepoUrl && environment?.gitToken) { + const user = await prisma.user.findUnique({ where: { id: userId } }); + const result = await gitSyncCommitPipeline( + { + repoUrl: environment.gitRepoUrl, + branch: environment.gitBranch ?? "main", + encryptedToken: environment.gitToken, + }, + environment.name, + pipeline.name, + configYaml, + { name: user?.name ?? "VectorFlow User", email: user?.email ?? "noreply@vectorflow" }, + changelog ?? `Deploy pipeline: ${pipeline.name}`, + ); + if (!result.success) { + gitSyncError = result.error; + } + } + // 4. For system pipelines, start the local Vector process instead of // relying on agents to pick up the config. if (pipeline.isSystem) { @@ -97,6 +123,7 @@ export async function deployAgent( success: true, versionId: version.id, versionNumber: version.version, + gitSyncError, }; } From 12a153e5c9848f50104e0d4c5524f352a22bb47d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:14:39 +0000 Subject: [PATCH 05/15] feat: wire git-sync delete into pipeline deletion flow --- src/server/routers/pipeline.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index 346110ca..967be2fd 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -16,6 +16,7 @@ import { generateVectorYaml } from "@/lib/config-generator"; import { getOrCreateSystemEnvironment } from "@/server/services/system-environment"; import { copyPipelineGraph } from "@/server/services/copy-pipeline-graph"; import { stripEnvRefs, type StrippedRef } from "@/server/services/strip-env-refs"; +import { gitSyncDeletePipeline } from "@/server/services/git-sync"; /** Pipeline names must be safe identifiers */ const pipelineNameSchema = z @@ -384,7 +385,7 @@ export const pipelineRouter = router({ .input(z.object({ id: z.string() })) .use(withTeamAccess("EDITOR")) .use(withAudit("pipeline.deleted", "Pipeline")) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const existing = await prisma.pipeline.findUnique({ where: { id: input.id }, }); @@ -409,6 +410,29 @@ export const pipelineRouter = router({ }); } + // Git sync: delete pipeline YAML from repo (non-blocking) + const environment = await prisma.environment.findUnique({ + where: { id: existing.environmentId }, + }); + if (environment?.gitRepoUrl && environment?.gitToken) { + const user = ctx.session?.user; + const dbUser = user?.id + ? await prisma.user.findUnique({ where: { id: user.id } }) + : null; + await gitSyncDeletePipeline( + { + repoUrl: environment.gitRepoUrl, + branch: environment.gitBranch ?? "main", + encryptedToken: environment.gitToken, + }, + environment.name, + existing.name, + { name: dbUser?.name ?? "VectorFlow User", email: dbUser?.email ?? "noreply@vectorflow" }, + ).catch((err) => { + console.error("[git-sync] Delete failed for pipeline:", existing.name, err); + }); + } + return prisma.pipeline.delete({ where: { id: input.id }, }); From b2dde37de0bde612d64e571fe5ecfb4ccc97cb86 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:16:20 +0000 Subject: [PATCH 06/15] feat: add git integration config to environment router --- src/server/routers/environment.ts | 51 ++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index dcbad64a..3b98aa79 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -4,6 +4,7 @@ import { router, protectedProcedure, withTeamAccess, requireSuperAdmin } from "@ import { prisma } from "@/lib/prisma"; import { withAudit } from "@/server/middleware/audit"; import { generateEnrollmentToken } from "@/server/services/agent-token"; +import { encrypt } from "@/server/services/crypto"; export const environmentRouter = router({ list: protectedProcedure @@ -94,12 +95,15 @@ export const environmentRouter = router({ name: z.string().min(1).max(100).optional(), secretBackend: z.enum(["BUILTIN", "VAULT", "AWS_SM", "EXEC"]).optional(), secretBackendConfig: z.any().optional(), + gitRepoUrl: z.string().url().optional().nullable(), + gitBranch: z.string().min(1).max(100).optional().nullable(), + gitToken: z.string().optional().nullable(), }) ) .use(withTeamAccess("EDITOR")) .use(withAudit("environment.updated", "Environment")) .mutation(async ({ input }) => { - const { id, ...data } = input; + const { id, gitToken, ...rest } = input; const existing = await prisma.environment.findUnique({ where: { id }, }); @@ -115,12 +119,57 @@ export const environmentRouter = router({ message: "The system environment cannot be modified directly", }); } + + // Build update data, encrypting git token if provided + const data: Record = { ...rest }; + if (gitToken !== undefined) { + data.gitToken = gitToken ? encrypt(gitToken) : null; + } + return prisma.environment.update({ where: { id }, data, }); }), + testGitConnection: protectedProcedure + .input(z.object({ + repoUrl: z.string().url(), + branch: z.string().min(1), + token: z.string().min(1), + })) + .use(withTeamAccess("EDITOR")) + .mutation(async ({ input }) => { + const simpleGit = (await import("simple-git")).default; + const { mkdtemp, rm } = await import("fs/promises"); + const { join } = await import("path"); + const { tmpdir } = await import("os"); + + let workdir: string | null = null; + try { + workdir = await mkdtemp(join(tmpdir(), "vf-git-test-")); + const git = simpleGit(workdir); + const url = new URL(input.repoUrl); + url.username = input.token; + url.password = ""; + await git.clone(url.toString(), workdir, [ + "--branch", input.branch, + "--depth", "1", + "--single-branch", + ]); + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } finally { + if (workdir) { + await rm(workdir, { recursive: true, force: true }).catch(() => {}); + } + } + }), + delete: protectedProcedure .input(z.object({ id: z.string() })) .use(withTeamAccess("ADMIN")) From 7af22ff41e1f8db62690827112519c263b8f49c9 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:19:14 +0000 Subject: [PATCH 07/15] feat: add Git Integration settings UI to environment detail page --- .../(dashboard)/environments/[id]/page.tsx | 8 + .../environment/git-sync-section.tsx | 208 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 src/components/environment/git-sync-section.tsx diff --git a/src/app/(dashboard)/environments/[id]/page.tsx b/src/app/(dashboard)/environments/[id]/page.tsx index 8afaf7c7..d9e15199 100644 --- a/src/app/(dashboard)/environments/[id]/page.tsx +++ b/src/app/(dashboard)/environments/[id]/page.tsx @@ -49,6 +49,7 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { SecretsSection } from "@/components/environment/secrets-section"; import { CertificatesSection } from "@/components/environment/certificates-section"; +import { GitSyncSection } from "@/components/environment/git-sync-section"; import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status"; export default function EnvironmentDetailPage({ @@ -514,6 +515,13 @@ export default function EnvironmentDetailPage({ + + {/* Created info */}

Created {new Date(env.createdAt).toLocaleDateString()} diff --git a/src/components/environment/git-sync-section.tsx b/src/components/environment/git-sync-section.tsx new file mode 100644 index 00000000..52d7cc1b --- /dev/null +++ b/src/components/environment/git-sync-section.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useState } from "react"; +import { useTRPC } from "@/trpc/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { GitBranch, Eye, EyeOff, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface GitSyncSectionProps { + environmentId: string; + gitRepoUrl: string | null; + gitBranch: string | null; + hasGitToken: boolean; +} + +export function GitSyncSection({ + environmentId, + gitRepoUrl, + gitBranch, + hasGitToken, +}: GitSyncSectionProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [repoUrl, setRepoUrl] = useState(gitRepoUrl ?? ""); + const [branch, setBranch] = useState(gitBranch ?? "main"); + const [token, setToken] = useState(""); + const [showToken, setShowToken] = useState(false); + const [isTesting, setIsTesting] = useState(false); + + const updateMutation = useMutation( + trpc.environment.update.mutationOptions({ + onSuccess: () => { + toast.success("Git integration settings saved"); + queryClient.invalidateQueries({ queryKey: trpc.environment.get.queryKey({ id: environmentId }) }); + setToken(""); // Clear token input after save + }, + onError: (err) => toast.error(err.message || "Failed to save Git settings"), + }) + ); + + const testMutation = useMutation( + trpc.environment.testGitConnection.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + toast.success("Git connection successful"); + } else { + toast.error("Git connection failed", { description: result.error }); + } + setIsTesting(false); + }, + onError: (err) => { + toast.error("Connection test failed", { description: err.message }); + setIsTesting(false); + }, + }) + ); + + function handleSave() { + updateMutation.mutate({ + id: environmentId, + gitRepoUrl: repoUrl || null, + gitBranch: branch || null, + gitToken: token || undefined, // Only send if user entered a new token + }); + } + + function handleTest() { + const testToken = token || undefined; + if (!repoUrl) { + toast.error("Enter a repository URL first"); + return; + } + if (!testToken && !hasGitToken) { + toast.error("Enter an access token first"); + return; + } + setIsTesting(true); + if (testToken) { + testMutation.mutate({ repoUrl, branch, token: testToken }); + } else { + toast.warning("Enter a new token to test the connection"); + setIsTesting(false); + } + } + + function handleDisconnect() { + updateMutation.mutate({ + id: environmentId, + gitRepoUrl: null, + gitBranch: null, + gitToken: null, + }); + setRepoUrl(""); + setBranch("main"); + setToken(""); + } + + const hasChanges = repoUrl !== (gitRepoUrl ?? "") || branch !== (gitBranch ?? "main") || token !== ""; + const isConfigured = !!gitRepoUrl; + + return ( + + +

+ +
+ Git Integration + + Automatically commit pipeline YAML to a Git repository on deploy and delete. + +
+
+ + +
+ + setRepoUrl(e.target.value)} + /> +
+ +
+ + setBranch(e.target.value)} + /> +
+ +
+ +
+
+ setToken(e.target.value)} + /> + +
+
+
+ +
+ + + {isConfigured && ( + + )} +
+
+ + ); +} From 178dc243c61e0c72f9247ead84cca41aec15b294 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:20:08 +0000 Subject: [PATCH 08/15] feat: show warning toast when git sync fails after deploy --- src/components/flow/deploy-dialog.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/flow/deploy-dialog.tsx b/src/components/flow/deploy-dialog.tsx index 0470ce09..79b12aff 100644 --- a/src/components/flow/deploy-dialog.tsx +++ b/src/components/flow/deploy-dialog.tsx @@ -57,6 +57,12 @@ export function DeployDialog({ pipelineId, open, onOpenChange }: DeployDialogPro toast.success("Pipeline published to agents", { description: result.versionNumber ? `Version v${result.versionNumber}` : undefined, }); + if (result.gitSyncError) { + toast.warning("Pipeline deployed but Git sync failed", { + description: result.gitSyncError, + duration: 8000, + }); + } onOpenChange(false); }, onError: (err) => { From a261103aea9c57437b201c299b919e25d230675a Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:22:04 +0000 Subject: [PATCH 09/15] docs: add git integration documentation --- docs/public/operations/security.md | 1 + docs/public/user-guide/environments.md | 32 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/docs/public/operations/security.md b/docs/public/operations/security.md index ad51401f..c91b7a73 100644 --- a/docs/public/operations/security.md +++ b/docs/public/operations/security.md @@ -59,6 +59,7 @@ VectorFlow encrypts sensitive data before storing it in PostgreSQL: | Certificates | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | OIDC client secret | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | Sensitive node config fields | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | +| Git access tokens | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | User passwords | bcrypt (cost 12) | Built-in salt | | TOTP secrets | AES-256-GCM | SHA-256 hash of `NEXTAUTH_SECRET` | | 2FA backup codes | SHA-256 hash | -- | diff --git a/docs/public/user-guide/environments.md b/docs/public/user-guide/environments.md index e32ed8b3..a5a30798 100644 --- a/docs/public/user-guide/environments.md +++ b/docs/public/user-guide/environments.md @@ -93,3 +93,35 @@ Secrets and certificates are stripped during promotion. After promoting a pipeli - **Edit** -- Click the **Edit** button on the environment detail page to rename the environment or change its secret backend configuration. - **Delete** -- Click the **Delete** button to permanently remove the environment. You must have the Admin role on the team to delete an environment. + +## Git Integration + +VectorFlow can automatically commit pipeline YAML files to a Git repository whenever a pipeline is deployed or deleted. This provides an audit trail, version history, and hook points for external CI/CD workflows. + +### Setup + +{% stepper %} +{% step %} +### Navigate to Environment Settings +Go to **Environments** and click on the environment you want to configure. +{% endstep %} +{% step %} +### Configure Git Integration +In the **Git Integration** card, enter: +- **Repository URL**: The HTTPS URL of your Git repo (e.g., `https://github.com/org/pipeline-configs.git`) +- **Branch**: The branch to commit to (default: `main`) +- **Access Token**: A personal access token with write access to the repo +{% endstep %} +{% step %} +### Test Connection +Click **Test Connection** to verify VectorFlow can reach the repository. +{% endstep %} +{% endstepper %} + +### How It Works + +When you deploy a pipeline, VectorFlow commits the generated YAML to `{environment-name}/{pipeline-name}.yaml` in the configured repository. When you delete a pipeline, the file is removed with a commit. + +{% hint style="info" %} +Git sync is a post-deploy side effect. If the Git push fails, the pipeline deploy still succeeds — you will see a warning toast in the UI. +{% endhint %} From d9876b89cbf74e9e62c5c7b336a75a178a1ad55c Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 11:27:54 +0000 Subject: [PATCH 10/15] fix: address code review findings for git integration - Strip encrypted gitToken from environment.get API response (defense-in-depth) - Clone into subdirectory of temp dir to avoid non-empty directory errors - Add HTTPS-only validation for git repo URLs (SSRF protection) - Add branch name regex validation - Sanitize git author name/email to prevent malformed author strings - Handle file-not-found gracefully in gitSyncDeletePipeline --- .../(dashboard)/environments/[id]/page.tsx | 2 +- src/server/routers/environment.ts | 21 ++++++--- src/server/services/git-sync.ts | 46 +++++++++++++------ 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/app/(dashboard)/environments/[id]/page.tsx b/src/app/(dashboard)/environments/[id]/page.tsx index d9e15199..ad92770b 100644 --- a/src/app/(dashboard)/environments/[id]/page.tsx +++ b/src/app/(dashboard)/environments/[id]/page.tsx @@ -519,7 +519,7 @@ export default function EnvironmentDetailPage({ environmentId={id} gitRepoUrl={env.gitRepoUrl} gitBranch={env.gitBranch} - hasGitToken={!!env.gitToken} + hasGitToken={env.hasGitToken} /> {/* Created info */} diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index 3b98aa79..c30f5432 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -53,9 +53,11 @@ export const environmentRouter = router({ }); } + const { gitToken, enrollmentTokenHash, ...safe } = environment; return { - ...environment, - hasEnrollmentToken: !!environment.enrollmentTokenHash, + ...safe, + hasEnrollmentToken: !!enrollmentTokenHash, + hasGitToken: !!gitToken, }; }), @@ -135,11 +137,16 @@ export const environmentRouter = router({ testGitConnection: protectedProcedure .input(z.object({ repoUrl: z.string().url(), - branch: z.string().min(1), + branch: z.string().min(1).max(100).regex(/^[a-zA-Z0-9._\/-]+$/), token: z.string().min(1), })) .use(withTeamAccess("EDITOR")) .mutation(async ({ input }) => { + const parsedUrl = new URL(input.repoUrl); + if (parsedUrl.protocol !== "https:") { + return { success: false, error: "Only HTTPS repository URLs are supported" }; + } + const simpleGit = (await import("simple-git")).default; const { mkdtemp, rm } = await import("fs/promises"); const { join } = await import("path"); @@ -148,11 +155,11 @@ export const environmentRouter = router({ let workdir: string | null = null; try { workdir = await mkdtemp(join(tmpdir(), "vf-git-test-")); + const repoDir = join(workdir, "repo"); const git = simpleGit(workdir); - const url = new URL(input.repoUrl); - url.username = input.token; - url.password = ""; - await git.clone(url.toString(), workdir, [ + parsedUrl.username = input.token; + parsedUrl.password = ""; + await git.clone(parsedUrl.toString(), repoDir, [ "--branch", input.branch, "--depth", "1", "--single-branch", diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts index d6aca781..4330f76f 100644 --- a/src/server/services/git-sync.ts +++ b/src/server/services/git-sync.ts @@ -33,6 +33,12 @@ function authenticatedUrl(repoUrl: string, token: string): string { return url.toString(); } +function sanitizeAuthor(name: string, email: string): string { + const cleanName = (name || "VectorFlow User").replace(/[<>\n\r]/g, ""); + const cleanEmail = email.replace(/[<>\n\r]/g, ""); + return `${cleanName} <${cleanEmail}>`; +} + /** * Slugify a string for use as a filename. */ @@ -61,33 +67,35 @@ export async function gitSyncCommitPipeline( const token = decrypt(config.encryptedToken); const url = authenticatedUrl(config.repoUrl, token); workdir = await mkdtemp(join(tmpdir(), "vf-git-sync-")); + const repoDir = join(workdir, "repo"); const git: SimpleGit = simpleGit(workdir); - await git.clone(url, workdir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + await git.clone(url, repoDir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + const repoGit: SimpleGit = simpleGit(repoDir); // Write the pipeline YAML file const envDir = toFilenameSlug(environmentName); const filename = `${toFilenameSlug(pipelineName)}.yaml`; const filePath = join(envDir, filename); - const fullPath = join(workdir, filePath); + const fullPath = join(repoDir, filePath); - await mkdir(join(workdir, envDir), { recursive: true }); + await mkdir(join(repoDir, envDir), { recursive: true }); await writeFile(fullPath, configYaml, "utf-8"); - await git.add(filePath); + await repoGit.add(filePath); // Check if there are actually changes to commit - const status = await git.status(); + const status = await repoGit.status(); if (status.isClean()) { return { success: true, commitSha: "no-change" }; } - await git.commit(commitMessage, filePath, { - "--author": `${author.name || "VectorFlow User"} <${author.email}>`, + await repoGit.commit(commitMessage, filePath, { + "--author": sanitizeAuthor(author.name, author.email), }); - await git.push("origin", config.branch); + await repoGit.push("origin", config.branch); - const log = await git.log({ maxCount: 1 }); + const log = await repoGit.log({ maxCount: 1 }); return { success: true, commitSha: log.latest?.hash }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -116,21 +124,29 @@ export async function gitSyncDeletePipeline( const token = decrypt(config.encryptedToken); const url = authenticatedUrl(config.repoUrl, token); workdir = await mkdtemp(join(tmpdir(), "vf-git-sync-")); + const repoDir = join(workdir, "repo"); const git: SimpleGit = simpleGit(workdir); - await git.clone(url, workdir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + await git.clone(url, repoDir, ["--branch", config.branch, "--depth", "1", "--single-branch"]); + const repoGit: SimpleGit = simpleGit(repoDir); const envDir = toFilenameSlug(environmentName); const filename = `${toFilenameSlug(pipelineName)}.yaml`; const filePath = join(envDir, filename); - await git.rm(filePath); - await git.commit(`Delete pipeline: ${pipelineName}`, filePath, { - "--author": `${author.name || "VectorFlow User"} <${author.email}>`, + try { + await repoGit.rm(filePath); + } catch { + // File not tracked in repo — nothing to delete + return { success: true, commitSha: "no-file" }; + } + + await repoGit.commit(`Delete pipeline: ${pipelineName}`, filePath, { + "--author": sanitizeAuthor(author.name, author.email), }); - await git.push("origin", config.branch); + await repoGit.push("origin", config.branch); - const log = await git.log({ maxCount: 1 }); + const log = await repoGit.log({ maxCount: 1 }); return { success: true, commitSha: log.latest?.hash }; } catch (err) { const message = err instanceof Error ? err.message : String(err); From 23c600b5cb0b1b5b3ce87d4f1de946436e0fec2d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 12:52:34 +0000 Subject: [PATCH 11/15] fix: address Greptile review findings for git integration - Sanitize git error messages to strip credentials from URLs - Add withAudit middleware to testGitConnection mutation - Add environmentId to testGitConnection for proper team scoping --- src/components/environment/git-sync-section.tsx | 2 +- src/server/routers/environment.ts | 6 +++++- src/server/services/git-sync.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/environment/git-sync-section.tsx b/src/components/environment/git-sync-section.tsx index 52d7cc1b..6702798a 100644 --- a/src/components/environment/git-sync-section.tsx +++ b/src/components/environment/git-sync-section.tsx @@ -88,7 +88,7 @@ export function GitSyncSection({ } setIsTesting(true); if (testToken) { - testMutation.mutate({ repoUrl, branch, token: testToken }); + testMutation.mutate({ environmentId, repoUrl, branch, token: testToken }); } else { toast.warning("Enter a new token to test the connection"); setIsTesting(false); diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index c30f5432..fc7e1da7 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -136,11 +136,13 @@ export const environmentRouter = router({ testGitConnection: protectedProcedure .input(z.object({ + environmentId: z.string(), repoUrl: z.string().url(), branch: z.string().min(1).max(100).regex(/^[a-zA-Z0-9._\/-]+$/), token: z.string().min(1), })) .use(withTeamAccess("EDITOR")) + .use(withAudit("environment.gitConnection.tested", "Environment")) .mutation(async ({ input }) => { const parsedUrl = new URL(input.repoUrl); if (parsedUrl.protocol !== "https:") { @@ -166,9 +168,11 @@ export const environmentRouter = router({ ]); return { success: true }; } catch (err) { + const raw = err instanceof Error ? err.message : String(err); + const sanitized = raw.replace(/https?:\/\/[^@\s]+@/g, "https://[redacted]@"); return { success: false, - error: err instanceof Error ? err.message : String(err), + error: sanitized, }; } finally { if (workdir) { diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts index 4330f76f..a62bbab1 100644 --- a/src/server/services/git-sync.ts +++ b/src/server/services/git-sync.ts @@ -33,6 +33,10 @@ function authenticatedUrl(repoUrl: string, token: string): string { return url.toString(); } +function sanitizeError(message: string): string { + return message.replace(/https?:\/\/[^@\s]+@/g, "https://[redacted]@"); +} + function sanitizeAuthor(name: string, email: string): string { const cleanName = (name || "VectorFlow User").replace(/[<>\n\r]/g, ""); const cleanEmail = email.replace(/[<>\n\r]/g, ""); @@ -98,7 +102,7 @@ export async function gitSyncCommitPipeline( const log = await repoGit.log({ maxCount: 1 }); return { success: true, commitSha: log.latest?.hash }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = sanitizeError(err instanceof Error ? err.message : String(err)); console.error("[git-sync] Commit failed:", message); return { success: false, error: message }; } finally { @@ -149,7 +153,7 @@ export async function gitSyncDeletePipeline( const log = await repoGit.log({ maxCount: 1 }); return { success: true, commitSha: log.latest?.hash }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = sanitizeError(err instanceof Error ? err.message : String(err)); console.error("[git-sync] Delete failed:", message); return { success: false, error: message }; } finally { From 742f34a38e6ef3836322041bb7fa6c26230a046f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 13:00:36 +0000 Subject: [PATCH 12/15] fix: set git committer identity for clean server environments --- src/server/services/git-sync.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts index a62bbab1..4b5dbd34 100644 --- a/src/server/services/git-sync.ts +++ b/src/server/services/git-sync.ts @@ -94,6 +94,8 @@ export async function gitSyncCommitPipeline( return { success: true, commitSha: "no-change" }; } + await repoGit.addConfig("user.name", author.name || "VectorFlow User"); + await repoGit.addConfig("user.email", author.email || "noreply@vectorflow"); await repoGit.commit(commitMessage, filePath, { "--author": sanitizeAuthor(author.name, author.email), }); @@ -145,6 +147,8 @@ export async function gitSyncDeletePipeline( return { success: true, commitSha: "no-file" }; } + await repoGit.addConfig("user.name", author.name || "VectorFlow User"); + await repoGit.addConfig("user.email", author.email || "noreply@vectorflow"); await repoGit.commit(`Delete pipeline: ${pipelineName}`, filePath, { "--author": sanitizeAuthor(author.name, author.email), }); From 52fa212c748ad8c5805a1ac949a6da51e836ca3b Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 13:08:41 +0000 Subject: [PATCH 13/15] fix: handle empty slug edge case and strip gitToken from update response --- src/server/routers/environment.ts | 8 +++++++- src/server/services/git-sync.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts index fc7e1da7..22e515a6 100644 --- a/src/server/routers/environment.ts +++ b/src/server/routers/environment.ts @@ -128,10 +128,16 @@ export const environmentRouter = router({ data.gitToken = gitToken ? encrypt(gitToken) : null; } - return prisma.environment.update({ + const updated = await prisma.environment.update({ where: { id }, data, }); + const { gitToken: _gt, enrollmentTokenHash: _eth, ...safeUpdate } = updated; + return { + ...safeUpdate, + hasEnrollmentToken: !!_eth, + hasGitToken: !!_gt, + }; }), testGitConnection: protectedProcedure diff --git a/src/server/services/git-sync.ts b/src/server/services/git-sync.ts index 4b5dbd34..105689c0 100644 --- a/src/server/services/git-sync.ts +++ b/src/server/services/git-sync.ts @@ -47,10 +47,11 @@ function sanitizeAuthor(name: string, email: string): string { * Slugify a string for use as a filename. */ export function toFilenameSlug(name: string): string { - return name + const slug = name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); + return slug || "unnamed"; } /** From ac69f3eacecb6872c59c6b8d792a1915b0a471c8 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 13:17:33 +0000 Subject: [PATCH 14/15] fix: defer disconnect state reset until mutation succeeds --- .../environment/git-sync-section.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/environment/git-sync-section.tsx b/src/components/environment/git-sync-section.tsx index 6702798a..cc1e028d 100644 --- a/src/components/environment/git-sync-section.tsx +++ b/src/components/environment/git-sync-section.tsx @@ -96,15 +96,21 @@ export function GitSyncSection({ } function handleDisconnect() { - updateMutation.mutate({ - id: environmentId, - gitRepoUrl: null, - gitBranch: null, - gitToken: null, - }); - setRepoUrl(""); - setBranch("main"); - setToken(""); + updateMutation.mutate( + { + id: environmentId, + gitRepoUrl: null, + gitBranch: null, + gitToken: null, + }, + { + onSuccess: () => { + setRepoUrl(""); + setBranch("main"); + setToken(""); + }, + }, + ); } const hasChanges = repoUrl !== (gitRepoUrl ?? "") || branch !== (gitBranch ?? "main") || token !== ""; From e38c3f6178832229145b4f496089bec82011ae30 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Fri, 6 Mar 2026 13:34:56 +0000 Subject: [PATCH 15/15] fix: use context-specific toasts and prevent testing button flash --- .../environment/git-sync-section.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/environment/git-sync-section.tsx b/src/components/environment/git-sync-section.tsx index cc1e028d..01ec6ec1 100644 --- a/src/components/environment/git-sync-section.tsx +++ b/src/components/environment/git-sync-section.tsx @@ -42,9 +42,7 @@ export function GitSyncSection({ const updateMutation = useMutation( trpc.environment.update.mutationOptions({ onSuccess: () => { - toast.success("Git integration settings saved"); queryClient.invalidateQueries({ queryKey: trpc.environment.get.queryKey({ id: environmentId }) }); - setToken(""); // Clear token input after save }, onError: (err) => toast.error(err.message || "Failed to save Git settings"), }) @@ -68,12 +66,20 @@ export function GitSyncSection({ ); function handleSave() { - updateMutation.mutate({ - id: environmentId, - gitRepoUrl: repoUrl || null, - gitBranch: branch || null, - gitToken: token || undefined, // Only send if user entered a new token - }); + updateMutation.mutate( + { + id: environmentId, + gitRepoUrl: repoUrl || null, + gitBranch: branch || null, + gitToken: token || undefined, // Only send if user entered a new token + }, + { + onSuccess: () => { + toast.success("Git integration settings saved"); + setToken(""); + }, + }, + ); } function handleTest() { @@ -86,12 +92,11 @@ export function GitSyncSection({ toast.error("Enter an access token first"); return; } - setIsTesting(true); if (testToken) { + setIsTesting(true); testMutation.mutate({ environmentId, repoUrl, branch, token: testToken }); } else { toast.warning("Enter a new token to test the connection"); - setIsTesting(false); } } @@ -105,6 +110,7 @@ export function GitSyncSection({ }, { onSuccess: () => { + toast.success("Git integration disconnected"); setRepoUrl(""); setBranch("main"); setToken("");