import { useEffect, useState } from 'react'; import { AlertTriangle } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker'; interface HostKeyRotationWarning { previous: string; current: string; } export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy'; /** * Mirror of the backend's env-path default (see `/api/stacks/from-git` and * the git-source PUT handler): if the user ticks "Sync .env" without * specifying an explicit path, the service reads `/.env` * alongside the primary compose file. Surfacing this in the form saves the * user a round-trip to figure out which directory the `.env` will come from. */ function computeDefaultEnvPath(composePath: string): string { const normalized = composePath.trim().replace(/\\/g, '/').replace(/^\.\//, ''); const slash = normalized.lastIndexOf('/'); if (slash === -1) return '.env'; return `${normalized.slice(0, slash)}/.env`; } export interface GitSourceFieldsState { repoUrl: string; branch: string; composePaths: string[]; contextDir: string; syncEnv: boolean; authType: 'none' | 'token' | 'deploy_key'; token: string; deployKey: string; sshKnownHostsEntry: string; sshHostKeyFingerprint: string; /** When editing an existing source, the server tells us whether a token is already stored. */ hasStoredToken: boolean; hasStoredDeployKey: boolean; storedHostKeyFingerprint: string | null; applyMode: ApplyMode; } export interface GitSourceFieldsProps extends GitSourceFieldsState { disabled?: boolean; /** When probing host keys from the edit panel, scopes the request to stack:edit. */ stackName?: string; /** 'edit' for the per-stack panel, 'create' for the new-stack dialog. Changes apply-mode copy. */ variant: 'edit' | 'create'; onRepoUrlChange: (value: string) => void; onBranchChange: (value: string) => void; onComposePathsChange: (value: string[]) => void; onContextDirChange: (value: string) => void; onSyncEnvChange: (value: boolean) => void; onAuthTypeChange: (value: 'none' | 'token' | 'deploy_key') => void; onTokenChange: (value: string) => void; onDeployKeyChange: (value: string) => void; onSshKnownHostsEntryChange: (value: string) => void; onSshHostKeyFingerprintChange: (value: string) => void; onApplyModeChange: (value: ApplyMode) => void; /** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */ onBrowse: () => Promise; } const APPLY_MODE_COPY: Record<'edit' | 'create', Record> = { edit: { 'review': { title: 'Review only', description: 'Webhook fetches and flags a pending diff. You apply manually.' }, 'auto-write': { title: 'Auto-write files', description: 'Webhook writes to disk. You deploy manually.' }, 'auto-deploy': { title: 'Auto-deploy', description: 'Webhook writes and deploys in one step.' }, }, create: { 'review': { title: 'Review only', description: 'Future webhook pulls surface a diff you apply manually.' }, 'auto-write': { title: 'Auto-write files', description: 'Future webhook pulls write to disk. You deploy manually.' }, 'auto-deploy': { title: 'Auto-deploy', description: 'Future webhook pulls write and redeploy automatically.' }, }, }; export function GitSourceFields({ repoUrl, branch, composePaths, contextDir, syncEnv, authType, token, deployKey, sshHostKeyFingerprint, hasStoredToken, hasStoredDeployKey, storedHostKeyFingerprint, applyMode, disabled = false, variant, onRepoUrlChange, onBranchChange, onComposePathsChange, onContextDirChange, onSyncEnvChange, onAuthTypeChange, onTokenChange, onDeployKeyChange, onSshKnownHostsEntryChange, onSshHostKeyFingerprintChange, onApplyModeChange, onBrowse, stackName, }: GitSourceFieldsProps) { const copy = APPLY_MODE_COPY[variant]; const primaryComposePath = composePaths[0] ?? ''; const canBrowse = !!repoUrl?.trim() && !!branch?.trim(); const [hostKeyRotation, setHostKeyRotation] = useState(null); useEffect(() => { setHostKeyRotation(null); }, [repoUrl]); const probeHostKey = async () => { if (!repoUrl.trim()) { toast.error('Enter a repository URL first.'); return; } try { const res = await apiFetch('/git-sources/ssh-host-key', { method: 'POST', body: JSON.stringify({ repo_url: repoUrl.trim(), ...(stackName ? { stack_name: stackName } : {}), }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to fetch host key.'); return; } const data = await res.json() as { keys?: Array<{ fingerprint: string; line: string }> }; const first = data.keys?.[0]; if (!first) { toast.error('No host keys returned.'); return; } const previousFingerprint = (storedHostKeyFingerprint ?? sshHostKeyFingerprint).trim(); if (previousFingerprint && previousFingerprint !== first.fingerprint) { setHostKeyRotation({ previous: previousFingerprint, current: first.fingerprint }); toast.warning('Host key fingerprint changed. Review the new fingerprint before saving.'); } else { setHostKeyRotation(null); if (!previousFingerprint) { toast.success(`Trusted host key fingerprint: ${first.fingerprint}`); } } onSshHostKeyFingerprintChange(first.fingerprint); onSshKnownHostsEntryChange(first.line); } catch (e) { toast.error((e as Error)?.message || 'Network error.'); } }; const radioOption = (mode: ApplyMode) => ( ); return (
onRepoUrlChange(e.target.value)} disabled={disabled} className="font-mono text-xs" />
onBranchChange(e.target.value)} disabled={disabled} className="font-mono text-xs" />
onSyncEnvChange(c === true)} disabled={disabled} />
{syncEnv && primaryComposePath.trim() !== '' && (

Will read{' '} {computeDefaultEnvPath(primaryComposePath)} {' '} from the repository.

)}
{authType === 'token' && (
onTokenChange(e.target.value)} disabled={disabled} className="font-mono text-xs" autoComplete="off" />

Token is encrypted at rest and never returned from the API.

)} {authType === 'deploy_key' && (
{hostKeyRotation && (

Host key fingerprint changed

The server presented a different key than the one you trusted. Confirm this is an expected rotation before saving.

Previously trusted: {hostKeyRotation.previous}

New fingerprint: {hostKeyRotation.current}

)}
{(sshHostKeyFingerprint || storedHostKeyFingerprint) && ( {sshHostKeyFingerprint || storedHostKeyFingerprint} )}