From 6529a245304438bb7120361cb05fcb9616917d32 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 15 Apr 2026 11:31:29 -0400 Subject: [PATCH] feat(git-sources): harden create-from-git with LFS + submodule warnings (#609) * feat(git-sources): surface LFS and submodule warnings on create Creating a stack from a Git repo now detects two common anomalies and tells the user about them rather than silently producing broken stacks. - LFS-pointer compose/env files fail early with a clear error instead of writing a 130-byte pointer stub to disk as real content. - Repositories containing .gitmodules produce a non-fatal warning so the user knows build contexts or volumes inside submodules will be empty at deploy time. Also refines the create dialog: sr-only DialogDescription for a11y, short commit SHA suffix on the success toast, env-path hint under the "Sync .env" checkbox showing which path will be read, and a route-level diagnostic log line gated on developer mode for support debugging. * test(git-sources): cover LFS, submodule, and nested env_path paths Adds unit coverage for the new LFS-pointer rejection and submodule warning plumbing, plus a nested compose_path case that exercises the default env_path resolution ("apps/web/compose.yaml" with sync_env on and env_path unset writes "apps/web/.env" both to disk and to the DB). Extends the E2E suite with a happy-path assertion that the full-length commit SHA is returned in the create response, and a UI flow that verifies the short-SHA suffix appears in the success toast. * docs(git-sources): add troubleshooting for LFS, submodules, HTTPS-only Adds troubleshooting entries for the newly surfaced LFS and submodule anomalies, expands the clone-timeout entry with the bounded-fetch explanation, and adds a dedicated HTTPS-only entry. Also consolidates the known limitations into a single list covering LFS, submodules, branch-tracking, and HTTPS-only. * fix(settings): use Route icon for notification routing The routing section in Settings previously used GitBranch, which now clashes with the Git Source feature's icon across the editor. Switch to Route (a branching-flow glyph) so routing rules have a distinct visual identity and aren't visually conflated with Git-backed stacks. * fix(git-sources): return 400 for upstream auth failures and disambiguate 404s Upstream git-host auth failures were mapping to HTTP 401, which the frontend apiFetch treats as a Sencho session expiry and fires the global logout event. They now return 400 with code=AUTH_FAILED in the body so the UI can branch on the discriminator without logging the user out. The status mapping moved into utils/gitSourceHttp so it can be unit-tested without booting the app. mapGitError also relied on the HttpError class alone, so any non-2xx response (including 404) was classified as auth failure. It now inspects the numeric status on err.data and considers whether a token was supplied, producing more actionable messages for missing repos, private repos, and wrong-scope tokens. --- backend/src/__tests__/git-source-http.test.ts | 59 +++++++ .../src/__tests__/git-source-service.test.ts | 161 +++++++++++++++++- backend/src/index.ts | 49 +++--- backend/src/services/GitSourceService.ts | 119 +++++++++++-- backend/src/utils/gitSourceHttp.ts | 37 ++++ docs/features/git-sources.mdx | 30 +++- e2e/git-sources.spec.ts | 67 ++++++++ frontend/src/components/EditorLayout.tsx | 23 ++- frontend/src/components/SettingsModal.tsx | 4 +- .../settings/NotificationRoutingSection.tsx | 6 +- .../src/components/stack/GitSourceFields.tsx | 45 +++-- 11 files changed, 536 insertions(+), 64 deletions(-) create mode 100644 backend/src/__tests__/git-source-http.test.ts create mode 100644 backend/src/utils/gitSourceHttp.ts diff --git a/backend/src/__tests__/git-source-http.test.ts b/backend/src/__tests__/git-source-http.test.ts new file mode 100644 index 00000000..cf0fb5f7 --- /dev/null +++ b/backend/src/__tests__/git-source-http.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for the git-source HTTP status mapping. + * + * These tests codify the design rule that AUTH_FAILED must not map to 401: + * the frontend's apiFetch treats 401 as a Sencho session expiry and fires a + * global logout event. A bad upstream git-host token is a user-fixable input + * error and must return 400 with `code: 'AUTH_FAILED'` in the body so the + * caller can still branch on the specific cause. + */ +import { describe, it, expect, vi } from 'vitest'; +import type { Response } from 'express'; +import { gitSourceStatus, sendGitSourceError } from '../utils/gitSourceHttp'; +import { GitSourceError } from '../services/GitSourceService'; + +describe('gitSourceStatus', () => { + it('maps AUTH_FAILED to 400, never 401', () => { + expect(gitSourceStatus('AUTH_FAILED')).toBe(400); + }); + + it('maps resource-missing codes to 404', () => { + expect(gitSourceStatus('REPO_NOT_FOUND')).toBe(404); + expect(gitSourceStatus('BRANCH_NOT_FOUND')).toBe(404); + expect(gitSourceStatus('FILE_NOT_FOUND')).toBe(404); + }); + + it('maps NETWORK_TIMEOUT to 504', () => { + expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504); + }); + + it('maps unknown codes to 400', () => { + expect(gitSourceStatus('GIT_ERROR')).toBe(400); + }); +}); + +describe('sendGitSourceError', () => { + function mockRes() { + const res = { status: vi.fn(), json: vi.fn() } as unknown as Response; + (res.status as ReturnType).mockReturnValue(res); + (res.json as ReturnType).mockReturnValue(res); + return res; + } + + it('sends 400 with code=AUTH_FAILED for upstream auth failures', () => { + const res = mockRes(); + sendGitSourceError(res, new GitSourceError('AUTH_FAILED', 'Repository authentication failed.')); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Repository authentication failed.', + code: 'AUTH_FAILED', + }); + }); + + it('sends 500 for unexpected (non-GitSourceError) failures', () => { + const res = mockRes(); + sendGitSourceError(res, new Error('unrelated crash')); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Git source operation failed' }); + }); +}); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index dde02917..9a3a68be 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -78,9 +78,13 @@ function mockSuccessfulClone(options: { mockGitClone.mockImplementation(async (args: { dir: string }) => { const { promises: fsp } = await import('fs'); const path = await import('path'); - await fsp.writeFile(path.join(args.dir, composePath), compose, 'utf-8'); + const composeAbs = path.join(args.dir, composePath); + await fsp.mkdir(path.dirname(composeAbs), { recursive: true }); + await fsp.writeFile(composeAbs, compose, 'utf-8'); if (env !== null && envPath) { - await fsp.writeFile(path.join(args.dir, envPath), env, 'utf-8'); + const envAbs = path.join(args.dir, envPath); + await fsp.mkdir(path.dirname(envAbs), { recursive: true }); + await fsp.writeFile(envAbs, env, 'utf-8'); } }); mockGitLog.mockResolvedValue([{ oid: sha }]); @@ -294,9 +298,50 @@ describe('GitSourceService error mapping', () => { composePath: 'compose.yaml', }; - it('maps 401/auth errors to AUTH_FAILED', async () => { - mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP 401 Unauthorized'), { code: 'HttpError' })); - await expect(svc().fetchFromGit(fetchParams)).rejects.toMatchObject({ code: 'AUTH_FAILED' }); + it('maps 401 with supplied token to AUTH_FAILED', async () => { + // A 401 only means "your token is wrong" when the caller actually sent one. + mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 401 Unauthorized'), { + code: 'HttpError', + data: { statusCode: 401 }, + })); + await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' })) + .rejects.toMatchObject({ code: 'AUTH_FAILED' }); + }); + + it('maps 401 without a token to REPO_NOT_FOUND with a private-repo hint', async () => { + // GitHub returns 404 for genuinely missing public repos but 401/403 can + // also reach us for private repos that the caller did not authenticate + // to. Without a supplied token, "check your token" is misleading, so we + // surface it as "not found or private" and suggest adding a PAT. + mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 401 Unauthorized'), { + code: 'HttpError', + data: { statusCode: 401 }, + })); + await expect(svc().fetchFromGit(fetchParams)) + .rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/private/i) }); + }); + + it('maps 404 HttpError to REPO_NOT_FOUND (not AUTH_FAILED)', async () => { + // Regression: isomorphic-git throws HttpError for every non-2xx, so a + // 404 on info/refs was previously misclassified as auth failure. + mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 404 Not Found'), { + code: 'HttpError', + data: { statusCode: 404 }, + })); + await expect(svc().fetchFromGit(fetchParams)) + .rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/private/i) }); + }); + + it('maps 404 with a supplied token to REPO_NOT_FOUND with a token-scope hint', async () => { + // GitHub returns 404 for both "missing repo" and "token lacks access", + // so when the caller did supply a token we point them at URL + scopes + // instead of "add a PAT" (which they already did). + mockGitClone.mockRejectedValueOnce(Object.assign(new Error('HTTP Error: 404 Not Found'), { + code: 'HttpError', + data: { statusCode: 404 }, + })); + await expect(svc().fetchFromGit({ ...fetchParams, token: 'ghp_some_token_value' })) + .rejects.toMatchObject({ code: 'REPO_NOT_FOUND', message: expect.stringMatching(/token has read access/i) }); }); it('maps 404/not-found errors to REPO_NOT_FOUND', async () => { @@ -486,6 +531,74 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => { }); }); +describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => { + const svc = () => GitSourceService.getInstance(); + // Real pointer files start with this exact header (git-lfs spec v1). + const LFS_POINTER = 'version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 1024\n'; + + it('rejects an LFS-pointer compose file with a GIT_ERROR mentioning LFS', async () => { + mockSuccessfulClone({ compose: LFS_POINTER }); + await expect(svc().fetchFromGit({ + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + })).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringMatching(/LFS/i), + }); + }); + + it('rejects an LFS-pointer env file with a GIT_ERROR mentioning LFS', async () => { + mockSuccessfulClone({ + compose: 'services:\n web:\n image: nginx\n', + env: LFS_POINTER, + envPath: '.env', + }); + await expect(svc().fetchFromGit({ + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + envPath: '.env', + })).rejects.toMatchObject({ + code: 'GIT_ERROR', + message: expect.stringMatching(/LFS/i), + }); + }); + + it('returns a submodule warning when .gitmodules is present', async () => { + mockGitClone.mockImplementation(async (args: { dir: string }) => { + const { promises: fsp } = await import('fs'); + const p = await import('path'); + await fsp.writeFile(p.join(args.dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n', 'utf-8'); + await fsp.writeFile( + p.join(args.dir, '.gitmodules'), + '[submodule "vendor"]\n\tpath = vendor\n\turl = https://github.com/example/vendor.git\n', + 'utf-8', + ); + }); + mockGitLog.mockResolvedValue([{ oid: 'abc1234567890abc1234567890abc1234567890a' }]); + + const result = await svc().fetchFromGit({ + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + }); + expect(result.warnings).toEqual( + expect.arrayContaining([expect.stringMatching(/submodules/i)]), + ); + }); + + it('returns no warnings when .gitmodules is absent', async () => { + mockSuccessfulClone(); + const result = await svc().fetchFromGit({ + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + }); + expect(result.warnings).toEqual([]); + }); +}); + describe('GitSourceService.pull', () => { it('rejects when no Git source is configured for the stack', async () => { const svc = GitSourceService.getInstance(); @@ -536,6 +649,44 @@ describe('GitSourceService.createStackFromGit', () => { await cleanupStackDir('create-happy'); }); + it('resolves a nested compose_path and nested env_path into the stack dir', async () => { + const sha = 'deadbeef1234567890deadbeef1234567890abcd'; + mockSuccessfulClone({ + compose: 'services:\n web:\n image: nginx\n', + env: 'FOO=nested\n', + composePath: 'apps/web/compose.yaml', + envPath: 'apps/web/.env', + sha, + }); + const svc = GitSourceService.getInstance(); + + const result = await svc.createStackFromGit({ + stackName: 'create-nested', + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'apps/web/compose.yaml', + syncEnv: true, + envPath: 'apps/web/.env', + authType: 'none', + token: null, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + + expect(result.envWritten).toBe(true); + expect(result.source.compose_path).toBe('apps/web/compose.yaml'); + expect(result.source.env_path).toBe('apps/web/.env'); + + const { FileSystemService } = await import('../services/FileSystemService'); + const env = await FileSystemService.getInstance().getEnvContent('create-nested'); + expect(env).toBe('FOO=nested\n'); + + const row = DatabaseService.getInstance().getGitSource('create-nested'); + expect(row?.env_path).toBe('apps/web/.env'); + + await cleanupStackDir('create-nested'); + }); + it('writes the env file when sync_env is enabled', async () => { const sha = '0101010101010101010101010101010101010101'; mockSuccessfulClone({ diff --git a/backend/src/index.ts b/backend/src/index.ts index 738f36a2..38d19826 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,7 +35,8 @@ import { SchedulerService } from './services/SchedulerService'; import { RegistryService } from './services/RegistryService'; import { CacheService } from './services/CacheService'; import { CAPABILITIES, getSenchoVersion, isValidVersion, fetchRemoteMeta, getActiveCapabilities, type RemoteMeta } from './services/CapabilityRegistry'; -import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, type GitSourceErrorCode } from './services/GitSourceService'; +import { GitSourceService, GitSourceError, sweepStaleTempDirs as sweepStaleGitTempDirs, repoHost as gitRepoHost } from './services/GitSourceService'; +import { sendGitSourceError } from './utils/gitSourceHttp'; // ── Hot-path cache TTLs ──────────────────────────────────────────────── // Short TTLs collapse concurrent polling pressure across browser tabs and @@ -3694,28 +3695,8 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { }); // ── Git sources ──────────────────────────────────────────────────────── -// Map GitSourceError codes to HTTP statuses so the UI can tell apart things -// a user can fix (bad token, missing file) from transient failures. -function gitSourceStatus(code: GitSourceErrorCode): number { - switch (code) { - case 'AUTH_FAILED': return 401; - case 'REPO_NOT_FOUND': - case 'BRANCH_NOT_FOUND': - case 'FILE_NOT_FOUND': - return 404; - case 'NETWORK_TIMEOUT': return 504; - default: return 400; - } -} - -function sendGitSourceError(res: Response, err: unknown): void { - if (err instanceof GitSourceError) { - res.status(gitSourceStatus(err.code)).json({ error: err.message, code: err.code }); - return; - } - console.error('[GitSource] Unexpected error:', err); - res.status(500).json({ error: 'Git source operation failed' }); -} +// Status mapping and error helper live in utils/gitSourceHttp so the +// mapping can be unit-tested without spinning up the full app. app.get('/api/git-sources', async (req: Request, res: Response) => { try { @@ -3933,6 +3914,9 @@ 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; + const fromGitStartedAt = Date.now(); + const fromGitDiag = isDebugEnabled(); + let fromGitStackName = ''; try { const { stack_name, @@ -3947,6 +3931,7 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => { auto_deploy_on_apply, deploy_now, } = req.body ?? {}; + fromGitStackName = typeof stack_name === 'string' ? stack_name : ''; if (typeof stack_name !== 'string' || !stack_name.trim()) { return res.status(400).json({ error: 'stack_name is required' }); @@ -3998,6 +3983,12 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => { : path.posix.join(path.posix.dirname(compose_path.replace(/\\/g, '/')) || '.', '.env')) : null; + if (fromGitDiag) { + console.log( + `[Stacks:diag] from-git start stack=${stack_name} nodeId=${req.nodeId ?? 'local'} host=${gitRepoHost(repo_url)} branch=${branch} composePath=${compose_path} envPath=${resolvedEnvPath ?? 'none'} authType=${resolvedAuthType} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}` + ); + } + const result = await GitSourceService.getInstance().createStackFromGit({ stackName: stack_name.trim(), repoUrl: repo_url.trim(), @@ -4031,15 +4022,27 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => { } console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`); + if (fromGitDiag) { + console.log( + `[Stacks:diag] from-git ok stack=${stack_name} sha=${result.commitSha.slice(0, 7)} deployed=${deployed} envWritten=${result.envWritten} warnings=${result.warnings.length} elapsedMs=${Date.now() - fromGitStartedAt}` + ); + } res.json({ name: stack_name, source: result.source, commitSha: result.commitSha, envWritten: result.envWritten, + warnings: result.warnings, deployed, deployError, }); } catch (error) { + if (fromGitDiag) { + const code = error instanceof GitSourceError ? error.code : 'UNKNOWN'; + console.log( + `[Stacks:diag] from-git fail stack=${fromGitStackName} code=${code} elapsedMs=${Date.now() - fromGitStartedAt}` + ); + } sendGitSourceError(res, error); } }); diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index dd755060..fb1bfe22 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -50,6 +50,12 @@ export interface FetchResult { composeContent: string; envContent: string | null; commitSha: string; + /** + * Non-fatal issues detected during the fetch (e.g. the repo uses + * submodules that are not cloned). The stack is still usable but the + * UI should surface these so the user is not surprised later. + */ + warnings: string[]; } export interface UpsertInput { @@ -82,6 +88,7 @@ export interface CreateStackFromGitResult { source: PublicGitSource; commitSha: string; envWritten: boolean; + warnings: string[]; } export interface PullResult { @@ -142,7 +149,7 @@ function scrubCredentials(message: string): string { * repo URL that could contain an inline credential. Falls back to * `unknown` for malformed URLs. */ -function repoHost(url: string): string { +export function repoHost(url: string): string { try { return new URL(url).host || 'unknown'; } catch { @@ -150,6 +157,41 @@ function repoHost(url: string): string { } } +/** + * Git LFS stores large files as small pointer stubs in the working tree. + * The pointer is a short text file that always begins with this line. + * isomorphic-git does not resolve LFS, so if the compose or env file is + * tracked through LFS we would silently write the pointer as content. + * Detect this and refuse, with a clear error, before it ever lands on + * disk. + */ +const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v'; + +function isLfsPointer(content: string): boolean { + // Pointer files are a few lines of ASCII, always starting with the + // version header on the first line. Check just the leading bytes so + // a very large plain file does not trigger a full scan. + return content.slice(0, LFS_POINTER_PREFIX.length) === LFS_POINTER_PREFIX; +} + +/** + * Check whether the cloned tree references Git submodules. We do not + * fetch submodule contents (isomorphic-git does not support them), so + * warn the caller that any paths inside submodule directories will be + * empty at deploy time. + */ +async function hasSubmodules(dir: string): Promise { + try { + const stat = await fsPromises.stat(path.join(dir, '.gitmodules')); + return stat.isFile() && stat.size > 0; + } catch { + return false; + } +} + +const SUBMODULE_WARNING = + 'Repository contains Git submodules. Their contents are not cloned; any paths referenced from them will be missing at deploy time.'; + /** * Reject any relative path that resolves into the `.git` metadata * directory. The path-traversal check in `fetchFromGit` already bounds @@ -377,7 +419,7 @@ export class GitSourceService { timeout, ]); } catch (e) { - throw this.mapGitError(e as Error); + throw this.mapGitError(e as Error, Boolean(token)); } finally { if (timer) clearTimeout(timer); } @@ -401,6 +443,13 @@ export class GitSourceService { } throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); } + if (isLfsPointer(composeContent)) { + console.error(`[GitSource] LFS pointer detected in ${composePath}`); + throw new GitSourceError( + 'GIT_ERROR', + `Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`, + ); + } let envContent: string | null = null; if (envPath) { @@ -420,14 +469,30 @@ export class GitSourceService { throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); } } + if (envContent !== null && isLfsPointer(envContent)) { + console.error(`[GitSource] LFS pointer detected in ${envPath}`); + throw new GitSourceError( + 'GIT_ERROR', + `Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`, + ); + } + } + + // Submodule detection: non-fatal, surfaced as a warning. isomorphic-git + // does not recursively clone submodules, so any path that lives inside + // a submodule directory will be empty after apply. Users need to know. + const warnings: string[] = []; + if (await hasSubmodules(dir)) { + console.warn(`[GitSource] Submodules detected in ${repoHost(repoUrl)}; contents not cloned.`); + warnings.push(SUBMODULE_WARNING); } if (diag) { console.log( - `[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} elapsedMs=${Date.now() - startedAt}` + `[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}` ); } - return { composeContent, envContent, commitSha }; + return { composeContent, envContent, commitSha, warnings }; } catch (err) { if (diag) { const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message; @@ -441,13 +506,43 @@ export class GitSourceService { } } - private mapGitError(err: Error): GitSourceError { + private mapGitError(err: Error, hasToken: boolean): GitSourceError { const raw = scrubCredentials(err.message || String(err)); const code = (err as Error & { code?: string }).code; + // isomorphic-git's HttpError exposes the numeric status on .data; inspect + // it directly so a 404 is not misclassified as auth failure. GitHub hides + // private-repo existence by returning 404 to unauthenticated requests, so + // we also treat 401/403 without a supplied token as "not found or private" + // to guide the user to add a token rather than "check your token" when + // they never provided one. + const statusCode = (err as Error & { data?: { statusCode?: number } }).data?.statusCode; - // isomorphic-git error codes - if (code === 'HttpError' || /401|403|authentication/i.test(raw)) { - return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.'); + // GitHub returns 404 for both "repo genuinely missing" and "private repo + // the caller cannot see". We cannot distinguish the two without a second + // probe, so tailor the hint by whether credentials were supplied: + // - no token: suggest adding one for private repos + // - token present: suggest checking the URL and token scopes, since a + // valid token against a missing or wrong-scoped repo also lands here + if (statusCode === 404) { + if (hasToken) { + return new GitSourceError('REPO_NOT_FOUND', 'Repository not found. Verify the URL and that your token has read access to this repo.'); + } + return new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.'); + } + if (statusCode === 401 || statusCode === 403) { + if (hasToken) { + return new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.'); + } + return new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.'); + } + + // Fallbacks for errors without a numeric status attached (e.g. git CLI + // output, DNS/lib errors, or future isomorphic-git transports that do + // not populate err.data.statusCode). Kept as defense-in-depth. + if (/401|403|authentication/i.test(raw)) { + return hasToken + ? new GitSourceError('AUTH_FAILED', 'Repository authentication failed. Check your token.') + : new GitSourceError('REPO_NOT_FOUND', 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.'); } if (code === 'NotFoundError' || /404|not found|could not resolve/i.test(raw)) { return new GitSourceError('REPO_NOT_FOUND', 'Repository not found or not accessible.'); @@ -458,6 +553,10 @@ export class GitSourceService { if (code === 'ECONNABORTED' || /timeout|timed out|ETIMEDOUT|ENOTFOUND|ECONNREFUSED/i.test(raw)) { return new GitSourceError('NETWORK_TIMEOUT', 'Network timeout or host unreachable.'); } + // Last-resort: an HttpError with a status we did not specifically handle. + if (code === 'HttpError') { + return new GitSourceError('GIT_ERROR', `Unexpected HTTP response from git host${statusCode ? ` (${statusCode})` : ''}.`); + } return new GitSourceError('GIT_ERROR', raw); } @@ -787,9 +886,9 @@ export class GitSourceService { 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}`); + console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten} warnings=${fetched.warnings.length}`); } - return { source, commitSha: fetched.commitSha, envWritten }; + return { source, commitSha: fetched.commitSha, envWritten, warnings: fetched.warnings }; } 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 diff --git a/backend/src/utils/gitSourceHttp.ts b/backend/src/utils/gitSourceHttp.ts new file mode 100644 index 00000000..1679cdf1 --- /dev/null +++ b/backend/src/utils/gitSourceHttp.ts @@ -0,0 +1,37 @@ +/** + * HTTP helpers for the git-source routes. + * + * Isolated here (instead of inlined in index.ts) so the status mapping is + * unit-testable without booting the full Express app. + * + * Design rule: never return 401 for a git-source error. On the frontend, + * `apiFetch` treats a 401 as "your Sencho session expired" and fires a + * global logout event. Upstream git-host auth failures (bad PAT, expired + * token, repo-level permission denied) are not that, and must not kick the + * user out of the app. Map those to 400 with `code: 'AUTH_FAILED'` so the + * UI can distinguish them by the body field, not the status. + */ +import type { Response } from 'express'; +import type { GitSourceErrorCode } from '../services/GitSourceService'; +import { GitSourceError } from '../services/GitSourceService'; + +export function gitSourceStatus(code: GitSourceErrorCode): number { + switch (code) { + case 'AUTH_FAILED': return 400; + case 'REPO_NOT_FOUND': + case 'BRANCH_NOT_FOUND': + case 'FILE_NOT_FOUND': + return 404; + case 'NETWORK_TIMEOUT': return 504; + default: return 400; + } +} + +export function sendGitSourceError(res: Response, err: unknown): void { + if (err instanceof GitSourceError) { + res.status(gitSourceStatus(err.code)).json({ error: err.message, code: err.code }); + return; + } + console.error('[GitSource] Unexpected error:', err); + res.status(500).json({ error: 'Git source operation failed' }); +} diff --git a/docs/features/git-sources.mdx b/docs/features/git-sources.mdx index a9be7196..5bb2288b 100644 --- a/docs/features/git-sources.mdx +++ b/docs/features/git-sources.mdx @@ -144,11 +144,14 @@ The in-browser editor and the Git Source panel both write to the same files, so - Verify the URL is reachable from the Sencho host and ends with `.git`. For private repos, confirm the token is present and has read access. If you rotated the token, open the panel and paste the new value. + Verify the URL is reachable from the Sencho host and ends with `.git`. GitHub returns a "not found" response for both genuinely missing repos and private repos you cannot read, so Sencho tailors the hint based on what you provided: + + - **No token configured**: the repo might be private. Switch **Authentication** to **Personal Access Token** and paste a token with read access. + - **Token configured**: double-check the URL is correct and the token has read access to this specific repo. GitHub fine-grained PATs need **Contents: Read** on the target repo; classic PATs need the `repo` scope. - Your token is missing, expired, or lacks read access to the repository. Generate a new token and replace the value in the **Token** field. + You supplied a token and the git host rejected it outright. The token is missing, expired, or lacks read access. Generate a new token and replace the value in the **Token** field. Sencho reports this as a form error, not a Sencho login problem, so you stay signed in. @@ -172,14 +175,29 @@ The in-browser editor and the Git Source panel both write to the same files, so - The clone did not finish in time. Check that the Sencho host can reach the repository host (proxies, firewalls, DNS) and try again. + The clone did not finish in time. Fetches run with a bounded timeout to keep a slow or unreachable host from hanging the stack panel. Check that the Sencho host can reach the repository host (proxies, firewalls, DNS) and try again. If the repository is genuinely large, pin a smaller compose subpath or mirror it somewhere closer to the Sencho host. The incoming compose file was written to disk successfully, but the subsequent `docker compose up -d` did not complete. The toast message includes the underlying reason (for example, an image pull failure or a port conflict). The stack is already on the new content, so you can retry the deploy directly from the editor's **Deploy** button without re-pulling. Fix the root cause first (image availability, host resources, network config) and redeploy. + + + If the compose file in your repository is tracked via Git LFS, Sencho will refuse the link and surface an LFS error rather than write a pointer stub as real content. Commit the plain compose file (and any synced `.env`) without LFS, or replace the LFS pointer in-place, then retry. + + + + Repositories that use Git submodules do not have their submodule contents cloned during a Git Source fetch. Sencho surfaces a warning on create when `.gitmodules` is present. If the compose file references paths inside a submodule (build contexts, volume mounts, include directives), inline the referenced files into the main repository or flatten the submodule so the paths resolve at deploy time. + + + + Git Sources fetch over HTTPS only. SSH clone URLs (`git@host:org/repo.git`) and custom protocols are rejected with a client-side validation error. Paste the `https://...` URL and use a Personal Access Token for authentication on private repositories. + - - Git Sources currently use HTTPS only. SSH URLs and SSH keys are not supported. - +## Known limitations + +- **HTTPS only.** SSH URLs and SSH keys are not supported. Use a Personal Access Token for private repos. +- **No Git LFS.** Compose and env files stored via LFS are rejected. Commit plain files instead. +- **No submodules.** Submodule contents are not fetched; paths inside a submodule directory will be missing at deploy time. A warning is shown on create when `.gitmodules` is present. +- **Branch-tracking only.** Sources follow the head of a branch. Specific commit SHAs and tags are not pinnable. diff --git a/e2e/git-sources.spec.ts b/e2e/git-sources.spec.ts index 52301025..c6a9a29d 100644 --- a/e2e/git-sources.spec.ts +++ b/e2e/git-sources.spec.ts @@ -310,5 +310,72 @@ test.describe('Create stack from Git', () => { }, CREATE_FROM_GIT_STACK); expect(contentStatus.status).toBe(200); expect(contentStatus.body).toMatch(/services:/); + // Backend contract: commitSha is returned at full length so the frontend + // can build the short-SHA suffix for the success toast. Guard it here so + // the toast copy can never drift without a test catching it. + expect(result.body?.commitSha).toMatch(/^[0-9a-f]{40}$/); + }); + + test('UI flow: success toast includes the short commit SHA', async ({ page }) => { + // Pre-flight check: if the upstream is unreachable from this runner, the + // UI flow will also fail. Probe the API with a throwaway name first so we + // skip cleanly instead of hanging on a dialog that never resolves. + const probeName = `${CREATE_FROM_GIT_STACK}-probe`; + const probe = 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', + deploy_now: false, + }), + }); + return { status: res.status }; + }, probeName); + + // Always tear down the probe, whether it succeeded or not. + 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(() => {}); + }, probeName); + + if (probe.status >= 400) { + test.skip(true, `Upstream unreachable (status ${probe.status}); skipping UI toast test`); + return; + } + + const uiName = `${CREATE_FROM_GIT_STACK}-ui`; + // Ensure no leftover row from a prior failing run. + 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(() => {}); + }, uiName); + + try { + await openCreateStackDialog(page); + await page.getByRole('dialog').getByRole('tab', { name: /From Git/i }).click(); + + await page.locator('#create-git-stack-name').fill(uiName); + await page.locator('#git-source-repo').fill('https://github.com/docker/awesome-compose.git'); + await page.locator('#git-source-branch').fill('master'); + await page.locator('#git-source-path').fill('nginx-golang/compose.yaml'); + + await page.getByRole('dialog').getByRole('button', { name: /Create from Git/i }).click(); + + // The toast copy is "Stack created from Git @ ." — match the + // @-delimited 7-char hex suffix so any drift in wording still passes as + // long as the SHA is surfaced. + await expect(page.getByText(/@ [0-9a-f]{7}/).first()).toBeVisible({ timeout: 20_000 }); + } finally { + 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(() => {}); + }, uiName); + } }); }); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 4977b747..a4217239 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -13,7 +13,7 @@ import { CapabilityGate } from './CapabilityGate'; import ResourcesView from './ResourcesView'; import { Button } from './ui/button'; import { Input } from './ui/input'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; @@ -1370,13 +1370,23 @@ export default function EditorLayout() { } throw new Error(err?.error || 'Failed to create stack from Git.'); } - const data: { deployed?: boolean; deployError?: string } = await response.json(); + const data: { + deployed?: boolean; + deployError?: string; + commitSha?: string; + warnings?: string[]; + } = await response.json(); + const shortSha = typeof data.commitSha === 'string' ? data.commitSha.slice(0, 7) : ''; + const shaSuffix = shortSha ? ` @ ${shortSha}` : ''; if (gitDeployNow && data.deployError) { - toast.warning(`Stack created, but deploy failed: ${data.deployError}`); + toast.warning(`Stack created${shaSuffix}, but deploy failed: ${data.deployError}`); } else if (gitDeployNow && data.deployed) { - toast.success('Stack created and deployed from Git.'); + toast.success(`Stack created and deployed from Git${shaSuffix}.`); } else { - toast.success('Stack created from Git.'); + toast.success(`Stack created from Git${shaSuffix}.`); + } + if (Array.isArray(data.warnings) && data.warnings.length > 0) { + toast.warning(data.warnings.join(' ')); } setCreateDialogOpen(false); resetCreateFromGitForm(); @@ -1533,6 +1543,9 @@ export default function EditorLayout() { Create New Stack + + Create a new stack, either empty or cloned from a Git repository. +
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 3190bff0..cc3daf1d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -15,7 +15,7 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; import type { SenchoSettingsChangedDetail } from '@/lib/events'; import { Shield, Activity, Bell, Code, Server, Package, - Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, GitBranch, + Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route, } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -389,7 +389,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal /> } label="Notifications" /> {!isRemote && isAdmin && ( - } label="Routing" locked={!isAdmiral} /> + } label="Routing" locked={!isAdmiral} /> )} {!isRemote && ( } label="Webhooks" locked={!isPaid} /> diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 90c1a4b4..9e9fd425 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -31,7 +31,7 @@ import { apiFetch } from '@/lib/api'; import { AdmiralGate } from '@/components/AdmiralGate'; import { CapabilityGate } from '@/components/CapabilityGate'; import { TierBadge } from '@/components/TierBadge'; -import { Plus, Trash2, Pencil, RefreshCw, Zap, X, GitBranch } from 'lucide-react'; +import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; interface NotificationRoute { id: number; @@ -355,7 +355,7 @@ export function NotificationRoutingSection() { {!loading && routes.length === 0 && (
- +

No routing rules configured.

Alerts will use your global notification channels. Add a route to direct specific stack alerts to dedicated channels. @@ -370,7 +370,7 @@ export function NotificationRoutingSection() { >

- + {route.name} {CHANNEL_LABELS[route.channel_type]} diff --git a/frontend/src/components/stack/GitSourceFields.tsx b/frontend/src/components/stack/GitSourceFields.tsx index 95a7af67..1ea9f73e 100644 --- a/frontend/src/components/stack/GitSourceFields.tsx +++ b/frontend/src/components/stack/GitSourceFields.tsx @@ -5,6 +5,20 @@ import { cn } from '@/lib/utils'; 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 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; @@ -130,16 +144,27 @@ export function GitSourceFields({
-
- onSyncEnvChange(c === true)} - disabled={disabled} - /> - +
+
+ onSyncEnvChange(c === true)} + disabled={disabled} + /> + +
+ {syncEnv && composePath.trim() !== '' && ( +

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

+ )}