diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index 001ddf97..e5c0e912 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -165,6 +165,68 @@ describe('git-source routes — invalid stack names', () => { }); }); +describe('POST /api/stacks/from-git', () => { + const validBody = { + stack_name: 'route-from-git', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'none' as const, + }; + + it('returns 401 without auth', async () => { + const res = await request(app).post('/api/stacks/from-git').send(validBody); + expect(res.status).toBe(401); + }); + + it('rejects missing stack_name with 400', async () => { + const { stack_name: _unused, ...body } = validBody; + void _unused; + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send(body); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/stack_name/i); + }); + + it('rejects invalid stack name with 400', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ ...validBody, stack_name: '../escape' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/stack name/i); + }); + + it('rejects http:// URLs with 400', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ ...validBody, repo_url: 'http://github.com/example/repo.git' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/HTTPS/i); + }); + + it('rejects oversized repo_url with 400', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ ...validBody, repo_url: 'https://example.com/' + 'a'.repeat(2048) }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/repo_url/i); + }); + + it('returns 409 when a stack with that name already exists on disk', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ ...validBody, stack_name: 'existing-stack' }); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/already exists/i); + }); +}); + describe('GET /api/git-sources', () => { it('returns 200 and a JSON array for an authenticated admin', async () => { const res = await request(app) diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 51243c0b..dde02917 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -493,6 +493,155 @@ describe('GitSourceService.pull', () => { }); }); +describe('GitSourceService.createStackFromGit', () => { + async function cleanupStackDir(name: string) { + const { FileSystemService } = await import('../services/FileSystemService'); + try { + await FileSystemService.getInstance().deleteStack(name); + } catch { + // directory may not exist; ignore + } + } + + it('creates a stack on disk, writes compose, and seeds last_applied columns', async () => { + const sha = 'fedcba9876543210fedcba9876543210fedcba98'; + mockSuccessfulClone({ + compose: 'services:\n web:\n image: nginx\n', + sha, + }); + const svc = GitSourceService.getInstance(); + + const result = await svc.createStackFromGit({ + stackName: 'create-happy', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + + expect(result.commitSha).toBe(sha); + expect(result.envWritten).toBe(false); + expect(result.source.last_applied_commit_sha).toBe(sha); + expect(result.source.pending_commit_sha).toBeNull(); + + const { FileSystemService } = await import('../services/FileSystemService'); + const onDisk = await FileSystemService.getInstance().getStackContent('create-happy'); + expect(onDisk).toContain('image: nginx'); + + await cleanupStackDir('create-happy'); + }); + + it('writes the env file when sync_env is enabled', async () => { + const sha = '0101010101010101010101010101010101010101'; + mockSuccessfulClone({ + compose: 'services:\n web:\n image: nginx\n', + env: 'FOO=bar\n', + envPath: '.env', + sha, + }); + const svc = GitSourceService.getInstance(); + + const result = await svc.createStackFromGit({ + stackName: 'create-env', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + syncEnv: true, + envPath: '.env', + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + expect(result.envWritten).toBe(true); + + const { FileSystemService } = await import('../services/FileSystemService'); + const env = await FileSystemService.getInstance().getEnvContent('create-env'); + expect(env).toBe('FOO=bar\n'); + + await cleanupStackDir('create-env'); + }); + + it('rejects an invalid apply-matrix without fetching or writing disk', async () => { + const svc = GitSourceService.getInstance(); + await expect(svc.createStackFromGit({ + stackName: 'create-bad-matrix', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: true, + })).rejects.toBeInstanceOf(GitSourceError); + + expect(mockGitClone).not.toHaveBeenCalled(); + expect(DatabaseService.getInstance().getGitSource('create-bad-matrix')).toBeUndefined(); + }); + + it('rejects when compose validation fails and leaves no stack/row behind', async () => { + mockSuccessfulClone({ + // Non-mapping root is rejected by validateCompose() pre-check + compose: '- not-a-mapping\n', + }); + const svc = GitSourceService.getInstance(); + + await expect(svc.createStackFromGit({ + stackName: 'create-bad-yaml', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + })).rejects.toMatchObject({ code: 'GIT_ERROR' }); + + expect(DatabaseService.getInstance().getGitSource('create-bad-yaml')).toBeUndefined(); + const { FileSystemService } = await import('../services/FileSystemService'); + const stacks = await FileSystemService.getInstance().getStacks(); + expect(stacks).not.toContain('create-bad-yaml'); + }); + + it('rolls back the stack dir when a post-create step fails', async () => { + mockSuccessfulClone({ + compose: 'services:\n web:\n image: nginx\n', + }); + const { FileSystemService } = await import('../services/FileSystemService'); + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent') + .mockRejectedValueOnce(new Error('simulated disk failure')); + + const svc = GitSourceService.getInstance(); + await expect(svc.createStackFromGit({ + stackName: 'create-rollback', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + syncEnv: false, + envPath: null, + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + })).rejects.toThrow(/simulated disk failure/); + + expect(DatabaseService.getInstance().getGitSource('create-rollback')).toBeUndefined(); + const stacks = await FileSystemService.getInstance().getStacks(); + expect(stacks).not.toContain('create-rollback'); + + saveSpy.mockRestore(); + }); +}); + describe('GitSourceService.apply', () => { async function seedPending(stackName: string, composeContent: string, commitSha: string) { mockSuccessfulClone({ compose: composeContent, sha: commitSha }); diff --git a/backend/src/index.ts b/backend/src/index.ts index fd001b20..738f36a2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3931,6 +3931,119 @@ app.post('/api/stacks', async (req: Request, res: Response) => { } }); +app.post('/api/stacks/from-git', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:create')) return; + try { + const { + stack_name, + repo_url, + branch, + compose_path, + sync_env, + env_path, + auth_type, + token, + auto_apply_on_webhook, + auto_deploy_on_apply, + deploy_now, + } = req.body ?? {}; + + if (typeof stack_name !== 'string' || !stack_name.trim()) { + return res.status(400).json({ error: 'stack_name is required' }); + } + if (!isValidStackName(stack_name)) { + return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' }); + } + if (typeof repo_url !== 'string' || !repo_url.trim()) { + return res.status(400).json({ error: 'repo_url is required' }); + } + if (typeof branch !== 'string' || !branch.trim()) { + return res.status(400).json({ error: 'branch is required' }); + } + if (typeof compose_path !== 'string' || !compose_path.trim()) { + return res.status(400).json({ error: 'compose_path is required' }); + } + const resolvedAuthType = auth_type === 'token' ? 'token' : 'none'; + if (!/^https:\/\//i.test(repo_url)) { + return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' }); + } + if (repo_url.length > 2048) { + return res.status(400).json({ error: 'repo_url is too long' }); + } + if (branch.length > 256) { + return res.status(400).json({ error: 'branch is too long' }); + } + if (compose_path.length > 1024) { + return res.status(400).json({ error: 'compose_path is too long' }); + } + if (typeof env_path === 'string' && env_path.length > 1024) { + return res.status(400).json({ error: 'env_path is too long' }); + } + if (typeof token === 'string' && token.length > 8192) { + return res.status(400).json({ error: 'token is too long' }); + } + + // Reject if a stack with this name already exists on disk. Without this + // the service would catch it at createStack() time, but erroring early + // avoids spinning up a temp clone we will not use. + const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); + if (stacks.includes(stack_name)) { + return res.status(409).json({ error: 'Stack already exists' }); + } + + const syncEnv = Boolean(sync_env); + const resolvedEnvPath = syncEnv + ? (typeof env_path === 'string' && env_path.trim() + ? env_path + : path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env')) + : null; + + const result = await GitSourceService.getInstance().createStackFromGit({ + stackName: stack_name.trim(), + repoUrl: repo_url.trim(), + branch: branch.trim(), + composePath: compose_path.trim(), + syncEnv, + envPath: resolvedEnvPath, + authType: resolvedAuthType, + token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null, + autoApplyOnWebhook: Boolean(auto_apply_on_webhook), + autoDeployOnApply: Boolean(auto_deploy_on_apply), + }); + + invalidateNodeCaches(req.nodeId); + + // Deploy is best-effort. The compose file is already on disk and the + // git source is linked, so a deploy failure does not roll back the + // stack; the user can retry the deploy from the editor. This mirrors + // the apply-then-deploy behavior in GitSourceService.apply(). + let deployed = false; + let deployError: string | undefined; + if (deploy_now === true) { + try { + await ComposeService.getInstance(req.nodeId).deployStack(stack_name); + deployed = true; + invalidateNodeCaches(req.nodeId); + } catch (e) { + deployError = getErrorMessage(e, 'Deploy failed'); + console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError); + } + } + + console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`); + res.json({ + name: stack_name, + source: result.source, + commitSha: result.commitSha, + envWritten: result.envWritten, + deployed, + deployError, + }); + } catch (error) { + sendGitSourceError(res, error); + } +}); + app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return; diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index de924420..dd755060 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -65,6 +65,25 @@ export interface UpsertInput { autoDeployOnApply: boolean; } +export interface CreateStackFromGitInput { + stackName: string; + repoUrl: string; + branch: string; + composePath: string; + syncEnv: boolean; + envPath: string | null; + authType: GitSourceAuthType; + token: string | null; + autoApplyOnWebhook: boolean; + autoDeployOnApply: boolean; +} + +export interface CreateStackFromGitResult { + source: PublicGitSource; + commitSha: string; + envWritten: boolean; +} + export interface PullResult { commitSha: string; incomingCompose: string; @@ -677,6 +696,117 @@ export class GitSourceService { DatabaseService.getInstance().clearGitSourcePending(stackName); } + // ─── Create stack from Git ─────────────────────────────────────────────── + + /** + * Fetch a compose file from a Git repository and use it to create a + * brand-new stack on disk + the matching git-source row. The caller is + * responsible for rolling back (deleteStack + deleteGitSource) if a + * later step such as an optional deploy fails; this method itself will + * undo its own partial state if anything *before* the DB insert fails. + * + * Serialized under the same per-stack mutex as pull/apply so a racing + * webhook cannot collide with a fresh create. + */ + public async createStackFromGit(input: CreateStackFromGitInput): Promise { + return this.withStackLock(input.stackName, async () => { + const fsSvc = FileSystemService.getInstance(); + const db = DatabaseService.getInstance(); + const diag = isDebugEnabled(); + + if (input.autoDeployOnApply && !input.autoApplyOnWebhook) { + throw new GitSourceError('GIT_ERROR', 'Auto-deploy requires auto-apply-on-webhook to be enabled.'); + } + + // 1. Fetch from git BEFORE touching disk or DB. If the fetch + // fails there is nothing to clean up. + const fetched = await this.fetchFromGit({ + repoUrl: input.repoUrl, + branch: input.branch, + composePath: input.composePath, + envPath: input.syncEnv ? input.envPath : null, + token: input.token, + }); + + // 2. Validate against the same `docker compose config` check the + // apply path uses. Reject before creating anything on disk. + const validation = await this.validateCompose(fetched.composeContent, fetched.envContent); + if (!validation.ok) { + throw new GitSourceError('GIT_ERROR', `Compose validation failed: ${validation.error}`); + } + + // 3. Create directory + boilerplate, then overwrite with the + // fetched content. createStack() throws if the directory + // already exists, so a name collision is caught here. + let stackCreated = false; + try { + await fsSvc.createStack(input.stackName); + stackCreated = true; + await fsSvc.saveStackContent(input.stackName, fetched.composeContent); + let envWritten = false; + if (input.syncEnv && fetched.envContent !== null) { + await fsSvc.saveEnvContent(input.stackName, fetched.envContent); + envWritten = true; + } + + // 4. Insert the git-source row, then mark it applied so future + // pulls diff against the fetched commit rather than treating + // it as "local edits detected". + const encryptedToken = input.authType === 'token' && input.token + ? this.crypto.encrypt(input.token) + : null; + db.upsertGitSource({ + stack_name: input.stackName, + repo_url: input.repoUrl, + branch: input.branch, + compose_path: input.composePath, + sync_env: input.syncEnv, + env_path: input.syncEnv ? input.envPath : null, + auth_type: input.authType, + encrypted_token: encryptedToken, + auto_apply_on_webhook: input.autoApplyOnWebhook, + auto_deploy_on_apply: input.autoDeployOnApply, + last_applied_commit_sha: fetched.commitSha, + last_applied_content_hash: this.hashContent(fetched.composeContent, fetched.envContent), + pending_commit_sha: null, + pending_compose_content: null, + pending_env_content: null, + pending_fetched_at: null, + last_debounce_at: null, + }); + db.markGitSourceApplied( + input.stackName, + fetched.commitSha, + this.hashContent(fetched.composeContent, fetched.envContent), + ); + + const source = this.get(input.stackName); + if (!source) { + throw new GitSourceError('GIT_ERROR', 'Failed to read back created git source.'); + } + + console.log(`[GitSource] Created stack ${input.stackName} from ${repoHost(input.repoUrl)} at ${fetched.commitSha.slice(0, 7)}`); + if (diag) { + console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten}`); + } + return { source, commitSha: fetched.commitSha, envWritten }; + } catch (e) { + // Roll back any partial on-disk state so the caller can retry + // cleanly. The DB row is only inserted at step 4, so an error + // earlier leaves nothing to clean in the DB. + if (stackCreated) { + try { + await fsSvc.deleteStack(input.stackName); + } catch (cleanupErr) { + console.error(`[GitSource] Rollback: failed to remove partial stack dir ${input.stackName}:`, cleanupErr); + } + } + db.deleteGitSource(input.stackName); + throw e; + } + }); + } + // ─── Webhook-triggered pull ────────────────────────────────────────────── /** diff --git a/docs/features/git-sources.mdx b/docs/features/git-sources.mdx index a31b48bf..a9be7196 100644 --- a/docs/features/git-sources.mdx +++ b/docs/features/git-sources.mdx @@ -16,6 +16,27 @@ Git Sources are available to all Sencho users on the Community tier. Writes land in the stack's existing directory using the same storage Sencho uses for the in-browser editor. Existing history, alerts, and metrics are unaffected. +## Create a stack from a Git repository + +Skip the "empty stack then link later" detour and point at a repo from the start. Click **Create Stack** in the sidebar, switch to the **From Git** tab, and fill in the same fields you would on the Git Source panel. + + + Create Stack dialog with the From Git tab selected, showing stack name, repository URL, branch, compose path, and a Deploy after create checkbox + + +Sencho fetches the compose file, validates it with `docker compose config`, writes it to a fresh stack directory, and links the git source in one step. The last-applied commit sha is seeded from the fetch so the first pull produces a clean diff rather than a "local edits detected" warning. + +Tick **Deploy after create** to run `docker compose up -d` immediately after the files land. If the deploy fails, the stack and git source are kept on disk so you can fix the underlying issue (missing image, port conflict, host resources) and retry the deploy from the editor. + +### Failure modes on create + +| Situation | What happens | +|-----------|--------------| +| Stack name already exists | Sencho returns **409** with "Stack already exists" and makes no changes on disk or in the database. Pick a different name or remove the existing stack. | +| Repository unreachable or auth failed | Fetch fails before anything is created. The form stays open with an error toast describing the cause. | +| Fetched compose fails validation | The stack directory is not created and no git source row is inserted. The error toast shows the `docker compose config` message. | +| Fetch + validate succeed but optional deploy fails | The stack and git source are kept. The toast reads "Stack created, but deploy failed: ..." and you can retry the deploy from the editor. | + ## Configure a source diff --git a/docs/images/git-sources/create-from-git-tab.png b/docs/images/git-sources/create-from-git-tab.png new file mode 100644 index 00000000..a4bf6531 Binary files /dev/null and b/docs/images/git-sources/create-from-git-tab.png differ diff --git a/e2e/git-sources.spec.ts b/e2e/git-sources.spec.ts index f41db175..52301025 100644 --- a/e2e/git-sources.spec.ts +++ b/e2e/git-sources.spec.ts @@ -198,3 +198,117 @@ test.describe('Git Sources', () => { await expect(page.getByRole('dialog').getByRole('button', { name: /^Remove$/ })).not.toBeVisible({ timeout: 5_000 }); }); }); + +const CREATE_FROM_GIT_STACK = 'e2e-create-from-git'; + +async function deleteCreateFromGitStack(page: Page) { + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}/git-source`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, CREATE_FROM_GIT_STACK); +} + +async function openCreateStackDialog(page: Page) { + await page.getByRole('button', { name: 'Create Stack' }).click(); + await expect(page.getByRole('dialog').getByText('Create New Stack')).toBeVisible({ timeout: 5_000 }); +} + +test.describe('Create stack from Git', () => { + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAs(page); + await deleteCreateFromGitStack(page); + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAs(page); + await deleteCreateFromGitStack(page); + await page.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAs(page); + await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); + }); + + test('dialog exposes Empty and From Git tabs', async ({ page }) => { + await openCreateStackDialog(page); + await expect(page.getByRole('dialog').getByRole('tab', { name: /Empty/i })).toBeVisible(); + await expect(page.getByRole('dialog').getByRole('tab', { name: /From Git/i })).toBeVisible(); + }); + + test('From Git tab rejects non-HTTPS URLs client-side', async ({ page }) => { + await openCreateStackDialog(page); + await page.getByRole('dialog').getByRole('tab', { name: /From Git/i }).click(); + + await page.locator('#create-git-stack-name').fill(CREATE_FROM_GIT_STACK); + await page.locator('#git-source-repo').fill('git@github.com:org/repo.git'); + await page.locator('#git-source-branch').fill('main'); + await page.locator('#git-source-path').fill('compose.yaml'); + + await page.getByRole('dialog').getByRole('button', { name: /Create from Git/i }).click(); + await expect(page.getByText(/Only HTTPS repository URLs are supported/i)).toBeVisible({ timeout: 5_000 }); + }); + + test('backend rejects .git/config compose_path on from-git', async ({ page }) => { + const body = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/from-git`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + stack_name: name, + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: '.git/config', + auth_type: 'none', + }), + }); + return { status: res.status, body: await res.json().catch(() => ({})) }; + }, CREATE_FROM_GIT_STACK); + expect(body.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(body.body)).toMatch(/\.git|file/i); + }); + + test('happy path: fetches compose, creates stack, links git source', async ({ page }) => { + // Use a public demo repo. If network egress is blocked, the POST fails + // and we skip the rest of the test rather than hanging the suite. + const result = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/from-git`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + stack_name: name, + repo_url: 'https://github.com/docker/awesome-compose.git', + branch: 'master', + compose_path: 'nginx-golang/compose.yaml', + auth_type: 'none', + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + deploy_now: false, + }), + }); + return { status: res.status, body: await res.json().catch(() => ({})) }; + }, CREATE_FROM_GIT_STACK); + + if (result.status >= 400) { + test.skip(true, `Upstream unreachable (status ${result.status}); skipping happy path`); + return; + } + expect(result.status).toBe(200); + expect(result.body?.source?.stack_name).toBe(CREATE_FROM_GIT_STACK); + expect(result.body?.source?.last_applied_commit_sha).toBeTruthy(); + + // Stack dir should now exist and the compose should contain the upstream service names. + const contentStatus = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}`, { credentials: 'include' }); + return { status: res.status, body: await res.text() }; + }, CREATE_FROM_GIT_STACK); + expect(contentStatus.status).toBe(200); + expect(contentStatus.body).toMatch(/services:/); + }); +}); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index e27ecc6f..4977b747 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -33,6 +33,8 @@ import { toast } from '@/components/ui/toast-store'; import { Label } from './ui/label'; import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; import { ScrollArea } from './ui/scroll-area'; +import { Checkbox } from './ui/checkbox'; +import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields'; import { Skeleton } from './ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; @@ -129,8 +131,19 @@ export default function EditorLayout() { const monacoEditorRef = useRef(null); const pendingStackLoadRef = useRef(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [createMode, setCreateMode] = useState<'empty' | 'git'>('empty'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); + // "From Git" tab state + const [gitRepoUrl, setGitRepoUrl] = useState(''); + const [gitBranch, setGitBranch] = useState('main'); + const [gitComposePath, setGitComposePath] = useState('compose.yaml'); + const [gitSyncEnv, setGitSyncEnv] = useState(false); + const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none'); + const [gitToken, setGitToken] = useState(''); + const [gitApplyMode, setGitApplyMode] = useState('review'); + const [gitDeployNow, setGitDeployNow] = useState(false); + const [creatingFromGit, setCreatingFromGit] = useState(false); const [stackToDelete, setStackToDelete] = useState(null); const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -1301,6 +1314,83 @@ export default function EditorLayout() { } }; + const resetCreateFromGitForm = () => { + setNewStackName(''); + setGitRepoUrl(''); + setGitBranch('main'); + setGitComposePath('compose.yaml'); + setGitSyncEnv(false); + setGitAuthType('none'); + setGitToken(''); + setGitApplyMode('review'); + setGitDeployNow(false); + }; + + const handleCreateStackFromGit = async () => { + const stackName = newStackName.trim(); + if (!stackName) { + toast.error('Stack name is required.'); + return; + } + if (!gitRepoUrl.trim() || !gitBranch.trim() || !gitComposePath.trim()) { + toast.error('Repository URL, branch, and compose path are required.'); + return; + } + if (!/^https:\/\//i.test(gitRepoUrl.trim())) { + toast.error('Only HTTPS repository URLs are supported.'); + return; + } + setCreatingFromGit(true); + const loadingId = toast.loading(gitDeployNow ? 'Fetching, creating, and deploying...' : 'Fetching and creating stack...'); + try { + const autoApply = gitApplyMode !== 'review'; + const autoDeploy = gitApplyMode === 'auto-deploy'; + const body: Record = { + stack_name: stackName, + repo_url: gitRepoUrl.trim(), + branch: gitBranch.trim(), + compose_path: gitComposePath.trim(), + sync_env: gitSyncEnv, + auth_type: gitAuthType, + auto_apply_on_webhook: autoApply, + auto_deploy_on_apply: autoDeploy, + deploy_now: gitDeployNow, + }; + if (gitAuthType === 'token' && gitToken !== '') { + body.token = gitToken; + } + const response = await apiFetch('/stacks/from-git', { + method: 'POST', + body: JSON.stringify(body), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + if (response.status === 409) { + throw new Error(err?.error || 'Stack already exists.'); + } + throw new Error(err?.error || 'Failed to create stack from Git.'); + } + const data: { deployed?: boolean; deployError?: string } = await response.json(); + if (gitDeployNow && data.deployError) { + toast.warning(`Stack created, but deploy failed: ${data.deployError}`); + } else if (gitDeployNow && data.deployed) { + toast.success('Stack created and deployed from Git.'); + } else { + toast.success('Stack created from Git.'); + } + setCreateDialogOpen(false); + resetCreateFromGitForm(); + await refreshStacks(); + await loadFile(stackName); + } catch (error) { + console.error('Failed to create stack from Git:', error); + toast.error((error as Error)?.message || 'Failed to create stack from Git.'); + } finally { + toast.dismiss(loadingId); + setCreatingFromGit(false); + } + }; + const openBashModal = (containerId: string, containerName: string) => { setSelectedContainer({ id: containerId, name: containerName }); setBashModalOpen(true); @@ -1427,29 +1517,119 @@ export default function EditorLayout() { {/* Create Stack & Scan Buttons */} {can('stack:create') &&
- + { + setCreateDialogOpen(o); + if (!o) { + setCreateMode('empty'); + resetCreateFromGitForm(); + } + }}> - - + + Create New Stack -
- - setNewStackName(e.target.value)} - /> + +
+ setCreateMode(v as 'empty' | 'git')}> + + + + + + Empty + + + + + + From Git + + + + +
- - - + + {createMode === 'empty' ? ( + <> +
+ + setNewStackName(e.target.value)} + /> +
+ + + + + ) : ( + <> + +
+
+ + setNewStackName(e.target.value)} + disabled={creatingFromGit} + /> +
+ + + +
+ setGitDeployNow(c === true)} + disabled={creatingFromGit} + /> + +
+
+
+ + + + + )}
diff --git a/frontend/src/components/stack/GitSourceFields.tsx b/frontend/src/components/stack/GitSourceFields.tsx new file mode 100644 index 00000000..95a7af67 --- /dev/null +++ b/frontend/src/components/stack/GitSourceFields.tsx @@ -0,0 +1,203 @@ +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; + +export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy'; + +export interface GitSourceFieldsState { + repoUrl: string; + branch: string; + composePath: string; + syncEnv: boolean; + authType: 'none' | 'token'; + token: string; + /** When editing an existing source, the server tells us whether a token is already stored. */ + hasStoredToken: boolean; + applyMode: ApplyMode; +} + +export interface GitSourceFieldsProps extends GitSourceFieldsState { + disabled?: boolean; + /** '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; + onComposePathChange: (value: string) => void; + onSyncEnvChange: (value: boolean) => void; + onAuthTypeChange: (value: 'none' | 'token') => void; + onTokenChange: (value: string) => void; + onApplyModeChange: (value: ApplyMode) => void; +} + +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, + composePath, + syncEnv, + authType, + token, + hasStoredToken, + applyMode, + disabled = false, + variant, + onRepoUrlChange, + onBranchChange, + onComposePathChange, + onSyncEnvChange, + onAuthTypeChange, + onTokenChange, + onApplyModeChange, +}: GitSourceFieldsProps) { + const copy = APPLY_MODE_COPY[variant]; + + 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" + /> +
+
+ + onComposePathChange(e.target.value)} + disabled={disabled} + className="font-mono text-xs" + /> +
+
+ +
+ onSyncEnvChange(c === true)} + disabled={disabled} + /> + +
+ +
+ +
+ + +
+ {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. +

+
+ )} +
+ +
+ +
+ {radioOption('review')} + {radioOption('auto-write')} + {radioOption('auto-deploy')} +
+
+
+ ); +} diff --git a/frontend/src/components/stack/GitSourcePanel.tsx b/frontend/src/components/stack/GitSourcePanel.tsx index e1fc507a..8e49b6f9 100644 --- a/frontend/src/components/stack/GitSourcePanel.tsx +++ b/frontend/src/components/stack/GitSourcePanel.tsx @@ -12,15 +12,12 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { cn } from '@/lib/utils'; import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog'; +import { GitSourceFields, type ApplyMode } from './GitSourceFields'; export interface GitSource { id: number; @@ -41,8 +38,6 @@ export interface GitSource { updated_at: number; } -type ApplyMode = 'review' | 'auto-write' | 'auto-deploy'; - interface GitSourcePanelProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -277,33 +272,6 @@ export function GitSourcePanel({ } }; - const radioOption = (mode: ApplyMode, title: string, description: string) => ( - - ); - return ( <> @@ -350,111 +318,25 @@ export function GitSourcePanel({
)} -
- - setRepoUrl(e.target.value)} - disabled={!canEdit || saving} - className="font-mono text-xs" - /> -
- -
-
- - setBranch(e.target.value)} - disabled={!canEdit || saving} - className="font-mono text-xs" - /> -
-
- - setComposePath(e.target.value)} - disabled={!canEdit || saving} - className="font-mono text-xs" - /> -
-
- -
- setSyncEnv(c === true)} - disabled={!canEdit || saving} - /> - -
- -
- -
- - -
- {authType === 'token' && ( -
- setToken(e.target.value)} - disabled={!canEdit || saving} - className="font-mono text-xs" - autoComplete="off" - /> -

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

-
- )} -
- -
- -
- {radioOption('review', 'Review only', 'Webhook fetches and flags a pending diff. You apply manually.')} - {radioOption('auto-write', 'Auto-write files', 'Webhook writes to disk. You deploy manually.')} - {radioOption('auto-deploy', 'Auto-deploy', 'Webhook writes and deploys in one step.')} -
-
+ {source && (