diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 461bff08668a..59da45a84d32 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -197,6 +197,106 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("returns actionable, transport-safe clone failures", () => { + const cases: ReadonlyArray<{ stderr: string; expected: string; remoteUrl?: string }> = [ + { + stderr: "Host key verification failed.\nfatal: Could not read from remote repository.\n", + expected: + "SSH could not verify the source control host. Add its host key to known_hosts and try again.", + }, + { + stderr: "git@example.com: Permission denied (publickey).\n", + expected: + "SSH authentication failed. Add an SSH key to your source control account and try again.", + }, + { + stderr: + "git@ssh.dev.azure.com: Public key authentication failed.\nfatal: Could not read from remote repository.\n", + expected: + "SSH authentication failed. Add an SSH key to your source control account and try again.", + }, + { + stderr: + "fatal: could not read Username for 'https://example.com': terminal prompts disabled\n", + expected: + "HTTPS authentication failed. Configure Git credentials for the source control host and try again.", + }, + { + stderr: + "ssh: Could not resolve hostname example.com: nodename nor servname provided\nfatal: Could not read from remote repository.\n", + expected: + "The source control host could not be resolved. Check your network or VPN connection and try again.", + }, + { + stderr: + "ssh: connect to host github.com port 22: Operation timed out\nfatal: Could not read from remote repository.\n", + expected: + "Git could not connect to the source control host. Check your network or VPN connection and try again.", + }, + ...( + [ + [ + CLONE_URLS.sshUrl, + "SSH authentication failed. Add an SSH key to your source control account and try again.", + ], + [ + CLONE_URLS.url, + "HTTPS authentication failed. Configure Git credentials for the source control host and try again.", + ], + [ + "custom::repository", + "Git authentication failed. Check the credentials configured for this remote and try again.", + ], + ] as const + ).map(([remoteUrl, expected]) => ({ + remoteUrl, + stderr: "fatal: Authentication failed", + expected, + })), + { + stderr: "fatal: an unrecognized clone failure\n", + expected: + "Git could not clone the repository. Verify that the remote works in a terminal and try again.", + }, + ] as const; + + return Effect.forEach(cases, ({ stderr, expected, remoteUrl }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-failure-", + }); + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: remoteUrl ?? CLONE_URLS.sshUrl, + destinationPath: `${parent}/t3code`, + }), + ); + + assert.strictEqual(error.operation, "cloneRepository"); + assert.strictEqual(error.provider, remoteUrl === "custom::repository" ? "unknown" : "github"); + assert.strictEqual(error.detail, expected); + assert.instanceOf(error.cause, GitCommandError); + assert.strictEqual(error.cause.detail, "git clone exited with a non-zero status."); + assert.strictEqual(error.cause.stderrLength, stderr.length); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: () => + Effect.succeed({ + ...processOutput(), + exitCode: ChildProcessSpawner.ExitCode(128), + stderr, + }), + }, + }), + ), + ), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + it.effect("preserves destination probe failures instead of treating them as missing paths", () => { const fileSystemCause = PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b38fe3d5c302..1730823e8a80 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -6,6 +6,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { + GitCommandError, SourceControlRepositoryError, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, @@ -18,12 +19,53 @@ import { type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + import { ServerConfig } from "../config.ts"; import { expandHomePathWith } from "../pathExpansion.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); +function cloneFailureDetail(stderr: string, remoteUrl?: string | null): string { + if (/host key verification failed/iu.test(stderr)) { + return "SSH could not verify the source control host. Add its host key to known_hosts and try again."; + } + if ( + /permission denied \(publickey(?:,[^)]+)?\)|public key authentication failed/iu.test(stderr) + ) { + return "SSH authentication failed. Add an SSH key to your source control account and try again."; + } + if ( + /http basic: access denied|could not read username|terminal prompts disabled/iu.test(stderr) + ) { + return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; + } + if (/authentication failed/iu.test(stderr)) { + if (remoteUrl && isSshRemoteUrl(remoteUrl)) { + return "SSH authentication failed. Add an SSH key to your source control account and try again."; + } + if (remoteUrl && /^https?:\/\//iu.test(remoteUrl)) { + return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; + } + return "Git authentication failed. Check the credentials configured for this remote and try again."; + } + if (/could not resolve (?:host|hostname)/iu.test(stderr)) { + return "The source control host could not be resolved. Check your network or VPN connection and try again."; + } + if (/connection (?:timed out|refused)|operation timed out|failed to connect/iu.test(stderr)) { + return "Git could not connect to the source control host. Check your network or VPN connection and try again."; + } + if (/repository not found|could not read from remote repository/iu.test(stderr)) { + return "The repository could not be read. Check that it exists and that your Git credentials have access."; + } + + return "Git could not clone the repository. Verify that the remote works in a terminal and try again."; +} + export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, { @@ -173,7 +215,10 @@ export const make = Effect.gen(function* () { const preparedDestination = yield* prepareDestination(input.destinationPath); let repository: SourceControlRepositoryInfo | null = null; let remoteUrl = input.remoteUrl?.trim() ?? null; - let provider: SourceControlProviderKind = input.provider ?? "unknown"; + let provider: SourceControlProviderKind = + input.provider ?? + (remoteUrl ? detectSourceControlProviderFromRemoteUrl(remoteUrl)?.kind : null) ?? + "unknown"; if (input.provider && input.repository) { repository = yield* lookupRepository({ @@ -193,14 +238,34 @@ export const make = Effect.gen(function* () { }); } - yield* git.execute({ + const cloneResult = yield* git.execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: preparedDestination.parentPath, args: ["clone", remoteUrl, preparedDestination.directoryName], + allowNonZeroExit: true, timeoutMs: 120_000, maxOutputBytes: 256 * 1024, }); + if (cloneResult.exitCode !== 0) { + const detail = cloneFailureDetail(cloneResult.stderr, remoteUrl); + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail, + cause: new GitCommandError({ + operation: "SourceControlRepositoryService.cloneRepository", + command: "git", + cwd: preparedDestination.parentPath, + argumentCount: 3, + exitCode: cloneResult.exitCode, + stdoutLength: cloneResult.stdout.length, + stderrLength: cloneResult.stderr.length, + detail: "git clone exited with a non-zero status.", + }), + }); + } + return { cwd: preparedDestination.destinationPath, remoteUrl,