323 lines
12 KiB
TypeScript
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, `'\\''`)}'`;
|
|
}
|