Files
vibn-frontend/vibn-frontend/lib/dev-container-git.ts

323 lines
12 KiB
TypeScript

/**
* Per-project dev-container git plumbing.
*
* The dev container's /workspace is a scratch volume by default. Without
* this module, anything the AI writes there is invisible to the user —
* the Product/Hosting/Infrastructure tabs all read from Gitea + Coolify,
* not from the dev container's filesystem.
*
* Two responsibilities:
*
* 1. ensureProjectRepoCloned()
* Clones the project's Gitea repo into /workspace/<slug>/ on first
* use. Configures the AI's git author identity and embeds Gitea
* credentials into the remote URL so subsequent pushes work without
* another credential exchange. Idempotent — if the repo already
* exists at the expected path, it's a no-op.
*
* 2. commitAndPushIfDirty()
* Run at the end of each AI assistant turn. If there are changes
* under /workspace/<slug>/, stages them, makes a commit with a
* one-line summary, and pushes to origin. Failures are logged but
* don't fail the chat turn — the user already saw their reply.
*
* Why we shell out vs. using a JS git lib: the dev container has a real
* git binary, the credentials are simple (HTTPS+token), and isomorphic
* git would mean shipping a ~2MB JS dep just to mirror what `git` already
* does. Cost outweighs benefit.
*
* Security note: the Gitea token is embedded in the remote URL, which
* lives in `.git/config` inside the dev container's volume. The volume
* is per-project and only reachable through our own SSH-bridge exec, so
* the blast radius is the same as `shell.exec` itself — anything the AI
* can do can read this token. That's already true today; this doesn't
* widen it.
*/
import { execInDevContainer } from '@/lib/dev-container';
const GITEA_API_URL = process.env.GITEA_API_URL ?? '';
// Falls back to GITEA_ADMIN_USER because production historically only set the
// admin var; missing GITEA_USERNAME used to silently disable auto-clone.
const GITEA_USERNAME =
process.env.GITEA_USERNAME || process.env.GITEA_ADMIN_USER || '';
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
const AI_GIT_AUTHOR_NAME = 'Vibn AI';
const AI_GIT_AUTHOR_EMAIL = 'ai@vibnai.com';
/** Where each project's repo lives inside the dev container. */
export function projectRepoPath(projectSlug: string): string {
return `/workspace/${projectSlug}`;
}
function isGiteaConfigured(): boolean {
return Boolean(GITEA_API_URL && GITEA_USERNAME && GITEA_API_TOKEN);
}
/**
* Take a Gitea HTTPS URL and embed the AI's credentials inline so
* `git clone` / `git push` work without an interactive prompt.
*
* Input: https://git.vibnai.com/vibn-mark/manifest.git
* Output: https://mark:<token>@git.vibnai.com/vibn-mark/manifest.git
*/
function authedCloneUrl(plainCloneUrl: string): string {
if (!isGiteaConfigured()) return plainCloneUrl;
try {
const u = new URL(plainCloneUrl);
u.username = GITEA_USERNAME;
u.password = GITEA_API_TOKEN;
return u.toString();
} catch {
return plainCloneUrl;
}
}
export interface EnsureRepoClonedOpts {
projectId: string;
projectSlug: string;
giteaCloneUrl: string;
}
export interface EnsureRepoClonedResult {
cloned: boolean;
alreadyPresent: boolean;
reason?: string;
}
/**
* Make sure the project's Gitea repo is checked out at /workspace/<slug>/.
*
* Skips silently if the repo is already present (detected by
* `git rev-parse --is-inside-work-tree`). Logs and returns a reason
* string if Gitea isn't configured or the clone fails — callers should
* treat clone failure as non-fatal: the dev container is still usable,
* the AI just won't be able to surface its work in the UI until the
* repo is fixed.
*/
export async function ensureProjectRepoCloned(
opts: EnsureRepoClonedOpts,
): Promise<EnsureRepoClonedResult> {
if (!isGiteaConfigured()) {
return { cloned: false, alreadyPresent: false, reason: 'gitea_not_configured' };
}
if (!opts.giteaCloneUrl) {
return { cloned: false, alreadyPresent: false, reason: 'no_clone_url' };
}
const repoDir = projectRepoPath(opts.projectSlug);
const authed = authedCloneUrl(opts.giteaCloneUrl);
// Tri-state probe:
// "git" → /workspace/<slug>/.git exists (proper repo, no-op)
// "dir" → /workspace/<slug>/ exists but isn't a repo (init in place)
// "absent" → nothing there yet (full clone)
//
// The "dir" case happens when an earlier AI turn (pre-this-fix)
// created files under /workspace/<slug>/ before we had auto-clone.
// We init-git in place so those files become the first commit
// instead of being clobbered by a clone-and-rename.
const probe = await execInDevContainer({
projectId: opts.projectId,
command:
`if [ -d ${shellQ(repoDir + '/.git')} ]; then echo git; ` +
`elif [ -d ${shellQ(repoDir)} ]; then echo dir; ` +
`else echo absent; fi`,
timeoutMs: 5_000,
});
const probeState = probe.stdout.trim();
if (probeState === 'git') {
return { cloned: false, alreadyPresent: true };
}
if (probeState === 'dir') {
// Init in place, hook up the remote, fetch, set tracking. We
// don't try to merge — the directory's contents become whatever
// the next auto-commit-and-push picks up. If the remote has
// existing commits that conflict, the push will fail loudly
// and the AI can resolve via shell.exec.
const initCmd = [
`cd ${shellQ(repoDir)}`,
`git init -b main`,
`git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
`git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
`git remote add origin ${shellQ(authed)}`,
// Best-effort fetch; if remote is empty this errors out and
// we proceed anyway. The `|| true` keeps the chain going.
`(git fetch origin main 2>/dev/null && git reset --soft origin/main 2>/dev/null) || true`,
].join(' && ');
const result = await execInDevContainer({
projectId: opts.projectId,
command: initCmd,
timeoutMs: 30_000,
});
if (result.exitCode !== 0) {
return {
cloned: false,
alreadyPresent: false,
reason: `init_in_place_failed:${result.stderr.slice(0, 200)}`,
};
}
return { cloned: true, alreadyPresent: false };
}
// probeState === 'absent' → full clone path.
// Clone into a temp path then atomic-rename, so a clone interrupted
// halfway through doesn't leave a half-checked-out tree at the real
// path that the next attempt would mistake for "already present".
const tmpDir = `${repoDir}.cloning.${Date.now()}`;
const cloneCmd = [
`mkdir -p /workspace`,
// --depth 50 keeps the dev container small; we don't need full
// history, and `--no-single-branch` would let the AI fetch other
// branches if needed. For empty repos this errors with "remote
// HEAD refers to nonexistent ref" — handle that with the dir
// fallback below.
`git clone --depth 50 ${shellQ(authed)} ${shellQ(tmpDir)}`,
`cd ${shellQ(tmpDir)}`,
`git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
`git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
`git remote set-url origin ${shellQ(authed)}`,
`cd /workspace && mv ${shellQ(tmpDir)} ${shellQ(repoDir)}`,
].join(' && ');
const result = await execInDevContainer({
projectId: opts.projectId,
command: cloneCmd,
timeoutMs: 60_000,
});
if (result.exitCode !== 0) {
await execInDevContainer({
projectId: opts.projectId,
command: `rm -rf ${shellQ(tmpDir)}`,
timeoutMs: 5_000,
}).catch(() => {});
// Empty-repo fallback: if Gitea returned the repo is empty
// (warning: "remote HEAD refers to nonexistent ref"), clone
// didn't actually fail — just there's no history to check
// out. Make an empty dir + init so subsequent commits land.
if (/empty|warning: You appear to have cloned an empty/i.test(result.stderr + result.stdout)) {
const initEmpty = [
`mkdir -p ${shellQ(repoDir)}`,
`cd ${shellQ(repoDir)}`,
`git init -b main`,
`git config user.name ${shellQ(AI_GIT_AUTHOR_NAME)}`,
`git config user.email ${shellQ(AI_GIT_AUTHOR_EMAIL)}`,
`git remote add origin ${shellQ(authed)}`,
].join(' && ');
const initR = await execInDevContainer({
projectId: opts.projectId,
command: initEmpty,
timeoutMs: 15_000,
}).catch(() => null);
if (initR && initR.exitCode === 0) {
return { cloned: true, alreadyPresent: false };
}
}
return {
cloned: false,
alreadyPresent: false,
reason: `clone_failed:${result.stderr.slice(0, 200)}`,
};
}
return { cloned: true, alreadyPresent: false };
}
export interface CommitAndPushOpts {
projectId: string;
projectSlug: string;
/** One-line summary; defaults to a generic checkpoint message. */
message?: string;
}
export interface CommitAndPushResult {
committed: boolean;
pushed: boolean;
/** Short reason ('clean', 'no_repo', 'commit_failed:<err>', etc.). */
reason?: string;
/** Commit hash if a commit was made. */
sha?: string;
}
/**
* Stage every change under /workspace/<slug>/, commit if there's a diff,
* and push to origin.
*
* Designed to be called at the end of each assistant turn. Cheap when
* there are no changes (one `git status --porcelain` and we're out).
* Never throws — the chat reply has already been streamed to the user
* by the time this runs, and a failed push shouldn't surface as a
* user-visible error in the conversation.
*/
export async function commitAndPushIfDirty(
opts: CommitAndPushOpts,
): Promise<CommitAndPushResult> {
const repoDir = projectRepoPath(opts.projectSlug);
const message = (opts.message ?? '').trim() || 'AI checkpoint';
// Sanitize: keep messages single-line and bounded so we can't be
// tricked into shell-escape or commit-message injection.
const safeMessage = message.replace(/[\r\n]+/g, ' ').slice(0, 200);
// Bail fast if there's no repo to commit against. Don't treat as
// an error — projects without a clone (e.g. cloning failed earlier)
// should still be able to chat.
const probe = await execInDevContainer({
projectId: opts.projectId,
command: `test -d ${shellQ(repoDir + '/.git')} && echo present || echo absent`,
timeoutMs: 5_000,
}).catch(() => null);
if (!probe || probe.stdout.trim() !== 'present') {
return { committed: false, pushed: false, reason: 'no_repo' };
}
const cmd = [
`cd ${shellQ(repoDir)}`,
`git add -A`,
// If there's nothing staged, `diff --cached --quiet` exits 0 and
// we short-circuit with a clean status. Otherwise commit + push.
`if git diff --cached --quiet; then echo CLEAN && exit 0; fi`,
`git commit -m ${shellQ(safeMessage)}`,
`SHA=$(git rev-parse --short HEAD)`,
`git push -u origin HEAD 2>&1 | tail -n 5`,
`echo COMMITTED $SHA`,
].join(' && ');
const result = await execInDevContainer({
projectId: opts.projectId,
command: cmd,
timeoutMs: 30_000,
}).catch((err: unknown) => ({
stdout: '',
stderr: err instanceof Error ? err.message : String(err),
exitCode: -1,
} satisfies { stdout: string; stderr: string; exitCode: number }));
const out = result.stdout || '';
if (out.includes('CLEAN')) {
return { committed: false, pushed: false, reason: 'clean' };
}
const m = /COMMITTED\s+([0-9a-f]+)/.exec(out);
if (m) {
return { committed: true, pushed: true, sha: m[1] };
}
// We staged something but didn't see the COMMITTED marker — likely
// commit succeeded but push failed (offline, auth rotated, etc.).
// Surface the stderr tail so debugging is possible.
return {
committed: false,
pushed: false,
reason: `commit_or_push_failed:${(result.stderr || out).slice(-200)}`,
};
}
function shellQ(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}