Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Live tests (`test/live/*.live.test.ts`, separate `vitest.live.config.ts`) skip t
- **Entry points** map 1:1 to package subpath exports: `src/index.ts` (core), `src/{github,gitlab,bitbucket,azure-devops,gitea,git-http,testing}.ts` are re-export barrels over `src/providers/<name>/`.
- **Fat client** (`src/client.ts`, `createClient`): input validation, capability gating, `listAll` async generators, bounded rate-limit retry (honors `Retry-After`), `AbortSignal` threading. **Thin adapters**: each provider implements the `RepoProvider` interface (`src/types.ts`) over the shared `HttpClient` (`src/http.ts`) with per-provider `authHeaders`/`mapError`/`secrets` hooks.
- **Capability gating over silent degradation**: providers declare `RepoCapabilities`; anything a provider can't do throws `RepoError` with `code: 'unsupported'` — never silently drop an option. Follow this when adding features.
- **Error model**: every failure is a `RepoError` (`src/errors.ts`) with a fixed code taxonomy; token values are redacted from messages via each provider's `secrets` hook. Exception by design: `getCloneUrl` returns credential-bearing URLs.
- **Error model**: every failure is a `RepoError` (`src/errors.ts`) with a fixed code taxonomy; token values are redacted from messages via each provider's `secrets` hook. Exception by design: `getCloneUrl` returns credential-bearing URLs and `getCloneCredentials` returns the credential itself.
- **Normalized models** (`src/types.ts`) always carry `raw: unknown` as the provider-payload escape hatch.
- **Pagination**: opaque base64url cursors are provider-tagged envelopes (`src/pagination.ts`) with a same-origin guard (`assertSameOriginUrl`) so a forged cursor can't redirect an authenticated request — keep this guard on any new cursor-following endpoint.
- **Webhooks**: `verifyWebhook` / `parseWebhookEvent` are standalone per-subpath exports (no client needed). Verification schemes differ per provider (GitHub/Bitbucket HMAC-SHA256, GitLab shared token, Azure Basic auth or the `webhookSecretHeader` factory option); all comparisons constant-time via `src/webhooks/verify.ts`. Push payloads normalize deletion pushes (all-zero SHA) to `headCommitSha: undefined` (`src/webhooks/parse.ts`).
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,16 @@ export async function POST(request: Request): Promise<Response> {

## API at a glance

| Namespace | Methods |
| ------------ | -------------------------------------------------------------- |
| `users` | `me` |
| `namespaces` | `list` · `listAll` |
| `repos` | `list` · `listAll` · `get` · `downloadArchive` · `getCloneUrl` |
| `commits` | `list` · `listAll` · `get` |
| `branches` | `list` · `listAll` · `get` |
| `tags` | `list` · `listAll` · `get` |
| `refs` | `resolve` · `search` |
| `webhooks` | `create` · `list` · `get` · `update` · `delete` |
| Namespace | Methods |
| ------------ | -------------------------------------------------------------------------------------- |
| `users` | `me` |
| `namespaces` | `list` · `listAll` |
| `repos` | `list` · `listAll` · `get` · `downloadArchive` · `getCloneCredentials` · `getCloneUrl` |
| `commits` | `list` · `listAll` · `get` |
| `branches` | `list` · `listAll` · `get` |
| `tags` | `list` · `listAll` · `get` |
| `refs` | `resolve` · `search` |
| `webhooks` | `create` · `list` · `get` · `update` · `delete` |

Every list returns an opaque cursor and has a `listAll` async generator that walks the pages for you. Cursors are provider-tagged and origin-checked, so a forged one cannot redirect an authenticated request. Every method accepts a `signal` for cancellation. Rate-limited requests are retried once when the provider's `Retry-After` fits the budget (10 seconds by default, configurable via `retry`).

Expand Down Expand Up @@ -162,7 +162,7 @@ try {
}
```

Errors also carry `provider`, `status`, `retryAfter` and `retryable`; `cause` is the underlying JS `Error`. Token values are redacted from every message, so a leaked stack trace cannot leak a credential. The one deliberate exception is `repos.getCloneUrl`, which returns a URL with the credential embedded because that is what `git clone` needs — treat it as a secret.
Errors also carry `provider`, `status`, `retryAfter` and `retryable`; `cause` is the underlying JS `Error`. Token values are redacted from every message, so a leaked stack trace cannot leak a credential. The deliberate exceptions are `repos.getCloneUrl`, which returns a URL with the credential embedded because that is what `git clone` needs, and `repos.getCloneCredentials`, which returns that credential next to a credential-free URL for a git credential helper — treat both as secrets.

## Runtime support

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/client-and-providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const client = createClient({

client.users; // me
client.namespaces; // list / listAll
client.repos; // list / listAll / get / downloadArchive / getCloneUrl
client.repos; // list / listAll / get / downloadArchive / getCloneCredentials / getCloneUrl
client.commits; // list / listAll / get
client.tags; // list / listAll / get
client.branches; // list / listAll / get
Expand Down
37 changes: 35 additions & 2 deletions docs/guides/downloading-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ sidebar:
---

There are two ways to get repository contents: `repos.downloadArchive` streams a `zip` or `tar.gz`
archive at a ref, and `repos.getCloneUrl` returns an authenticated clone URL. The clone URL embeds
credentials — treat it as a secret and never log it.
archive at a ref, or you clone an authenticated remote. For cloning, `repos.getCloneUrl` returns a URL
with the credential embedded — treat it as a secret and never log it — while
`repos.getCloneCredentials` returns that credential next to a credential-free URL.

## Downloading an archive

Expand Down Expand Up @@ -89,6 +90,38 @@ Both an OAuth `accessToken` and an Entra ID `tokenProvider` embed the token in t
(typically ~1 hour) and the URL carries no `expiresAt`, so fetch the clone URL just before cloning
rather than storing it.

## Getting clone credentials

`repos.getCloneCredentials` returns the same credential split from the URL, so you can feed it to a
git credential helper instead of persisting a tokenized remote in `.git/config`:

<TypeTable
type={{
expiresAt: { type: 'Date', required: false, description: 'When the credential expires (e.g. GitHub App tokens, ~1h).' },
password: { type: 'string | null', description: 'The secret. Null when the provider carries the token in username, or when access is anonymous.' },
url: { type: 'string', description: 'The clone URL, without any embedded credential.' },
username: { type: 'string | null', description: 'The username the provider expects. Null for anonymous access.' },
}}
/>

```ts
const { url, username, password } = await client.repos.getCloneCredentials({
repo: 'capawesome-team/repo-sdk',
});
// url: 'https://github.com/capawesome-team/repo-sdk.git'
```

`username` and `password` are raw values — percent-encode them yourself if you put them back into a
URL. `getCloneUrl` is exactly that: these credentials embedded as userinfo.

Two shapes need a branch. Gitea passes the token as the username with no password, so `password` is
`null`. A `git-http` remote configured without `auth` clones anonymously, so both are `null`.

:::warning
`password` — and `username` where the token sits there — is a credential. **Treat it as a secret and
never log it.**
:::

<Card title="Next: Managing webhooks" icon="webhook" href="/docs/guides/webhooks-managing">
Register and manage repository webhooks.
</Card>
7 changes: 7 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import type {
Archive,
AuthenticatedUser,
Branch,
CloneCredentials,
CloneUrl,
Commit,
CreateWebhookParams,
DeleteWebhookParams,
DownloadArchiveParams,
GetAuthenticatedUserParams,
GetBranchParams,
GetCloneCredentialsParams,
GetCloneUrlParams,
GetCommitParams,
GetRepositoryParams,
Expand Down Expand Up @@ -63,6 +65,7 @@ export interface RepoClient {
listAll(params?: Omit<ListRepositoriesParams, 'cursor'>): AsyncGenerator<Repository, void>;
get(params: GetRepositoryParams): Promise<Repository>;
downloadArchive(params: DownloadArchiveParams): Promise<Archive>;
getCloneCredentials(params: GetCloneCredentialsParams): Promise<CloneCredentials>;
getCloneUrl(params: GetCloneUrlParams): Promise<CloneUrl>;
};
commits: {
Expand Down Expand Up @@ -239,6 +242,10 @@ export function createClient(options: CreateClientOptions): RepoClient {
}
return withRetry(() => provider.downloadArchive({ ...params, format }));
},
getCloneCredentials: async (params) => {
requireNonEmpty(params.repo, 'repo');
return withRetry(() => provider.getCloneCredentials(params));
},
getCloneUrl: async (params) => {
requireNonEmpty(params.repo, 'repo');
return withRetry(() => provider.getCloneUrl(params));
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ export type {
ArchiveFormat,
AuthenticatedUser,
Branch,
CloneCredentials,
CloneUrl,
Commit,
CreateWebhookParams,
DeleteWebhookParams,
DownloadArchiveParams,
GetAuthenticatedUserParams,
GetBranchParams,
GetCloneCredentialsParams,
GetCloneUrlParams,
GetCommitParams,
GetRepositoryParams,
Expand Down
35 changes: 21 additions & 14 deletions src/providers/azure-devops/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import type {
Archive,
AuthenticatedUser,
Branch,
CloneCredentials,
CloneUrl,
Commit,
CreateWebhookParams,
DeleteWebhookParams,
DownloadArchiveParams,
GetAuthenticatedUserParams,
GetBranchParams,
GetCloneCredentialsParams,
GetCloneUrlParams,
GetCommitParams,
GetRepositoryParams,
Expand All @@ -36,7 +38,12 @@ import type {
Webhook,
WebhookEventType,
} from '../../types.ts';
import { commitWebUrlBuilder, filenameFromContentDisposition, isRecord } from '../shared.ts';
import {
commitWebUrlBuilder,
filenameFromContentDisposition,
isRecord,
toCloneUrl,
} from '../shared.ts';
import {
API_VERSION,
authHeader,
Expand Down Expand Up @@ -346,6 +353,16 @@ export function azureDevOps(options: AzureDevOpsProviderOptions): RepoProvider {
return ref;
}

async function getCloneCredentials(params: GetCloneCredentialsParams): Promise<CloneCredentials> {
const { project, repository } = splitRepo(params.repo);
const host = new URL(baseUrl).host;
const url = `https://${host}/${enc(options.organization)}/${enc(project)}/_git/${enc(repository)}`;
if ('pat' in auth) return { password: auth.pat, url, username: 'pat' };
if ('accessToken' in auth) return { password: auth.accessToken, url, username: 'oauth2' };
const token = await auth.tokenProvider({ forceRefresh: false });
return { password: token, url, username: 'oauth2' };
}

return {
name: 'azure-devops',
capabilities: CAPABILITIES,
Expand Down Expand Up @@ -583,20 +600,10 @@ export function azureDevOps(options: AzureDevOpsProviderOptions): RepoProvider {
};
},

getCloneCredentials,

async getCloneUrl(params: GetCloneUrlParams): Promise<CloneUrl> {
const { project, repository } = splitRepo(params.repo);
const host = new URL(baseUrl).host;
const repoPath = `${enc(options.organization)}/${enc(project)}/_git/${enc(repository)}`;
if ('pat' in auth) {
return { url: `https://pat:${encodeURIComponent(auth.pat)}@${host}/${repoPath}` };
}
if ('accessToken' in auth) {
return {
url: `https://oauth2:${encodeURIComponent(auth.accessToken)}@${host}/${repoPath}`,
};
}
const token = await auth.tokenProvider({ forceRefresh: false });
return { url: `https://oauth2:${encodeURIComponent(token)}@${host}/${repoPath}` };
return toCloneUrl(await getCloneCredentials(params));
},

async createWebhook(params: CreateWebhookParams): Promise<Webhook> {
Expand Down
23 changes: 14 additions & 9 deletions src/providers/bitbucket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ import {
commitWebUrlBuilder,
filenameFromContentDisposition,
isRecord,
toCloneUrl,
} from '../shared.ts';
import type {
Archive,
AuthenticatedUser,
Branch,
CloneCredentials,
CloneUrl,
Commit,
CreateWebhookParams,
DeleteWebhookParams,
DownloadArchiveParams,
GetAuthenticatedUserParams,
GetBranchParams,
GetCloneCredentialsParams,
GetCloneUrlParams,
GetCommitParams,
GetRepositoryParams,
Expand Down Expand Up @@ -334,6 +337,14 @@ export function bitbucket(options: BitbucketProviderOptions): RepoProvider {
return `${repoPath(repo)}/hooks/${encodeURIComponent(id)}`;
}

async function getCloneCredentials(params: GetCloneCredentialsParams): Promise<CloneCredentials> {
const url = `https://${GIT_HOST}/${params.repo}.git`;
if (isApiTokenAuth(auth)) {
return { password: auth.apiToken, url, username: 'x-bitbucket-api-token-auth' };
}
return { password: await currentToken(false), url, username: 'x-token-auth' };
}

return {
name: 'bitbucket',
capabilities: CAPABILITIES,
Expand Down Expand Up @@ -541,16 +552,10 @@ export function bitbucket(options: BitbucketProviderOptions): RepoProvider {
};
},

getCloneCredentials,

async getCloneUrl(params: GetCloneUrlParams): Promise<CloneUrl> {
if (isApiTokenAuth(auth)) {
return {
url: `https://x-bitbucket-api-token-auth:${encodeURIComponent(auth.apiToken)}@${GIT_HOST}/${params.repo}.git`,
};
}
const token = await currentToken(false);
return {
url: `https://x-token-auth:${encodeURIComponent(token)}@${GIT_HOST}/${params.repo}.git`,
};
return toCloneUrl(await getCloneCredentials(params));
},

async createWebhook(params: CreateWebhookParams): Promise<Webhook> {
Expand Down
19 changes: 12 additions & 7 deletions src/providers/git-http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { stringToBase64 } from '../../base64.ts';
import { RepoError } from '../../errors.ts';
import { HttpClient, type ProviderErrorInfo } from '../../http.ts';
import { decodeCursor, encodeCursor } from '../../pagination.ts';
import { clampPerPage } from '../shared.ts';
import { clampPerPage, toCloneUrl } from '../shared.ts';
import type {
Branch,
CloneCredentials,
CloneUrl,
GetBranchParams,
GetCloneCredentialsParams,
GetCloneUrlParams,
GetRepositoryParams,
GetTagParams,
Expand Down Expand Up @@ -298,6 +300,12 @@ export function gitHttp(options: GitHttpProviderOptions = {}): RepoProvider {
};
}

async function getCloneCredentials(params: GetCloneCredentialsParams): Promise<CloneCredentials> {
const url = normalizeRepoUrl(params.repo);
if (!auth) return { password: null, url, username: null };
return { password: await currentSecret(false), url, username };
}

return {
name: 'git-http',
capabilities: CAPABILITIES,
Expand Down Expand Up @@ -421,13 +429,10 @@ export function gitHttp(options: GitHttpProviderOptions = {}): RepoProvider {
throw unsupported('archive downloads');
},

getCloneCredentials,

async getCloneUrl(params: GetCloneUrlParams): Promise<CloneUrl> {
const repoUrl = normalizeRepoUrl(params.repo);
if (!auth) return { url: repoUrl };
const url = new URL(repoUrl);
url.username = username;
url.password = await currentSecret(false);
return { url: url.toString() };
return toCloneUrl(await getCloneCredentials(params));
},

async createWebhook(): Promise<never> {
Expand Down
22 changes: 16 additions & 6 deletions src/providers/gitea/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ import {
filenameFromContentDisposition,
isRecord,
parseLinkNext,
toCloneUrl,
} from '../shared.ts';
import type {
Archive,
AuthenticatedUser,
Branch,
CloneCredentials,
CloneUrl,
Commit,
CreateWebhookParams,
DeleteWebhookParams,
DownloadArchiveParams,
GetAuthenticatedUserParams,
GetBranchParams,
GetCloneCredentialsParams,
GetCloneUrlParams,
GetCommitParams,
GetRepositoryParams,
Expand Down Expand Up @@ -363,6 +366,16 @@ export function gitea(options: GiteaProviderOptions): RepoProvider {
return `/repos/${repo}`;
}

async function getCloneCredentials(params: GetCloneCredentialsParams): Promise<CloneCredentials> {
const host = new URL(baseUrl).host;
// Gitea accepts the access token as the basic-auth username with no password.
return {
password: null,
url: `https://${host}/${params.repo}.git`,
username: await currentToken(false),
};
}

return {
name: 'gitea',
capabilities: CAPABILITIES,
Expand Down Expand Up @@ -600,13 +613,10 @@ export function gitea(options: GiteaProviderOptions): RepoProvider {
};
},

getCloneCredentials,

async getCloneUrl(params: GetCloneUrlParams): Promise<CloneUrl> {
const host = new URL(baseUrl).host;
// Gitea accepts the access token as the basic-auth username with no password.
const token = await currentToken(false);
return {
url: `https://${encodeURIComponent(token)}@${host}/${params.repo}.git`,
};
return toCloneUrl(await getCloneCredentials(params));
},

async createWebhook(params: CreateWebhookParams): Promise<Webhook> {
Expand Down
Loading
Loading