@amonstack/gitea-mcp 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,7 @@ projects. Set them only to override the discovery.
59
59
  | Variable | Required | Description |
60
60
  |----------|:--------:|-------------|
61
61
  | `GITEA_BASE_URL` | No | Gitea instance URL (e.g. `https://gitea.example.com`). Auto-detected from the project's git remote when omitted. |
62
- | `GITEA_TOKEN` | No | Gitea API access token. Resolved from `.git/config`, the git credential store, then this variable (see [Token discovery](#token-discovery)). |
62
+ | `GITEA_TOKEN` | No | Gitea API access token. One of several auth candidates; tried after a `.git/config [gitea]` token and before the git credential store (see [Token discovery](#token-discovery)). |
63
63
  | `GITEA_DEFAULT_OWNER` | No | Default repository owner — skip passing `owner` on every call |
64
64
  | `GITEA_DEFAULT_REPO` | No | Default repository name — skip passing `repo` on every call |
65
65
 
@@ -80,21 +80,48 @@ repository, or set `GITEA_BASE_URL` / `GITEA_TOKEN` explicitly.
80
80
 
81
81
  ### Token discovery
82
82
 
83
- The token is resolved in this order (first match wins):
83
+ `gitea-mcp` collects authentication **candidates** from three sources, in this
84
+ priority order:
84
85
 
85
- 1. A `[gitea "<baseUrl>"]` section in `.git/config`:
86
+ 1. A `[gitea "<baseUrl>"]` section in `.git/config` (a bare `[gitea]` section is
87
+ a host-wide fallback):
86
88
  ```ini
87
89
  [gitea "https://gitea.example.com"]
88
90
  token = <your-token>
89
91
  ```
90
- A bare `[gitea]` section with `token = ...` is a host-wide fallback.
91
- 2. The git credential store (`~/.git-credentials`, or `$XDG_CONFIG_HOME/git/credentials`)
92
- a line whose host matches the instance, e.g. `https://oauth2:<token>@gitea.example.com`.
93
- 3. The `GITEA_TOKEN` environment variable.
94
-
95
- If none resolves, the server still starts without a token (anonymous). Public repositories
96
- may be read; private repos and write operations return `401` — use the `gitea-configure`
97
- skill to guide setup, or set `GITEA_TOKEN`.
92
+ Always sent as `Authorization: token <token>`.
93
+ 2. The `GITEA_TOKEN` environment variable also sent as `Authorization: token`.
94
+ 3. The git credential store (`~/.git-credentials`, or
95
+ `$XDG_CONFIG_HOME/git/credentials`) every line whose host matches the
96
+ instance, e.g. `https://alice:s3cret@gitea.example.com`. When several lines
97
+ match, the one whose path best matches `owner/repo` is tried first.
98
+
99
+ A credential-store entry's `password` field may hold a real PAT, an account
100
+ password, or an OAuth token — git stores whatever was typed at the prompt, and
101
+ the server cannot tell them apart statically. So each credential-store entry is
102
+ tried under **two authentication schemes**:
103
+
104
+ - `Authorization: Basic <base64(user:pass)>` — works for account passwords and
105
+ PATs alike (Gitea checks that the username matches the secret's owner).
106
+ - `Authorization: token <secret>` — works only for real PATs.
107
+
108
+ The order is chosen by a username heuristic: a convention username
109
+ (`oauth2`, `x-oauth-basic`, or empty) tries `token` first; a real-looking
110
+ username (e.g. `alice`) tries `basic` first.
111
+
112
+ **Fault tolerance.** On `401`/`403` the server advances to the next
113
+ scheme/candidate and retries the same request; once a combination succeeds it is
114
+ locked for the rest of the session (no re-probing). Non-auth errors (`404`,
115
+ `500`, network) propagate immediately and do **not** trigger a retry.
116
+
117
+ **Diagnostics.** The `gitea_status` tool (see [Repository Helpers](#repository-helpers))
118
+ returns a redacted view of the current state — which candidate is active, which
119
+ are exhausted, the last status seen — without ever exposing the secret. Use it
120
+ to troubleshoot a `401` instead of guessing.
121
+
122
+ If no source resolves a credential, the server still starts anonymously. Public
123
+ repositories may be read; private repos and write operations return `401` — use
124
+ the `gitea-configure` skill to guide setup, or set `GITEA_TOKEN`.
98
125
 
99
126
  When `GITEA_DEFAULT_OWNER` and `GITEA_DEFAULT_REPO` are set, you can omit the
100
127
  `owner` and `repo` parameters in tool calls. The `resolve_repo` tool can also
@@ -234,6 +261,7 @@ gitea-mcp
234
261
  |------|-------------|
235
262
  | `list_my_repos` | List repositories accessible to the authenticated user |
236
263
  | `resolve_repo` | Detect `baseUrl`, `owner`, and `repo` from the project's git remotes (`upstream` preferred, then `origin`) |
264
+ | `gitea_status` | Inspect credential-handling state — active candidate, exhausted candidates, last error (redacted; secrets never exposed) |
237
265
 
238
266
  ## AI Guidance & Skills
239
267
 
@@ -290,6 +318,7 @@ npm ci
290
318
  | `make test` | Run unit tests |
291
319
  | `make test-watch` | Run tests in watch mode |
292
320
  | `make test-integration` | Run integration tests (needs live Gitea instance) |
321
+ | `make scan` | Scan for leaked secrets with gitleaks (part of `make verify`) |
293
322
  | `make dev` | Run directly with tsx |
294
323
 
295
324
  For the full architecture — module layout, dependency graph, core patterns, and
package/README.zh-CN.md CHANGED
@@ -56,7 +56,7 @@ node dist/cli.js
56
56
  | 变量 | 必填 | 说明 |
57
57
  |------|:----:|------|
58
58
  | `GITEA_BASE_URL` | 否 | Gitea 实例地址(如 `https://gitea.example.com`)。未设置时从项目 git 远程地址自动推导。 |
59
- | `GITEA_TOKEN` | 否 | Gitea API 访问令牌。依次从 `.git/config`、git 凭据存储、本变量解析(见[令牌发现](#令牌发现))。 |
59
+ | `GITEA_TOKEN` | 否 | Gitea API 访问令牌。是多个认证候选之一;排在 `.git/config [gitea]` 令牌之后、git 凭据存储之前(见[令牌发现](#令牌发现))。 |
60
60
  | `GITEA_DEFAULT_OWNER` | 否 | 默认仓库所有者,免去每次传入 `owner` 参数 |
61
61
  | `GITEA_DEFAULT_REPO` | 否 | 默认仓库名称,免去每次传入 `repo` 参数 |
62
62
 
@@ -76,20 +76,40 @@ node dist/cli.js
76
76
 
77
77
  ### 令牌发现
78
78
 
79
- 令牌按以下顺序解析(先匹配者胜出):
79
+ `gitea-mcp` 从三个来源收集认证**候选**,按以下优先级排序:
80
80
 
81
- 1. `.git/config` 中的 `[gitea "<baseUrl>"]` 段:
81
+ 1. `.git/config` 中的 `[gitea "<baseUrl>"]` 段(不带地址的 `[gitea]` 段作为全局兜底):
82
82
  ```ini
83
83
  [gitea "https://gitea.example.com"]
84
84
  token = <your-token>
85
85
  ```
86
- 不带地址的 `[gitea]` 段中的 `token = ...` 作为全局兜底。
87
- 2. git 凭据存储(`~/.git-credentials`,或 `$XDG_CONFIG_HOME/git/credentials`)——host 匹配实例
88
- 的那一行,如 `https://oauth2:<token>@gitea.example.com`。
89
- 3. `GITEA_TOKEN` 环境变量。
86
+ 始终以 `Authorization: token <token>` 发送。
87
+ 2. `GITEA_TOKEN` 环境变量 —— 同样以 `Authorization: token` 发送。
88
+ 3. git 凭据存储(`~/.git-credentials`,或 `$XDG_CONFIG_HOME/git/credentials`)—— host 匹配实例
89
+ 的每一行,如 `https://alice:s3cret@gitea.example.com`。多行同时匹配时,path `owner/repo`
90
+ 最贴近的那一行优先尝试。
90
91
 
91
- 若都未解析到,服务器仍会以匿名方式启动。公开仓库可读;私有仓库和写操作返回 `401`——此时使用
92
- `gitea-configure` 技能引导配置,或设置 `GITEA_TOKEN`。
92
+ 凭据存储条目的 `password` 字段可能是真正的 PAT、账户登录密码,或 OAuth token —— git 存的是
93
+ 用户在密码提示里输入的任何内容,服务端无法静态区分。因此每个凭据存储条目会以**两种认证方案**
94
+ 依次尝试:
95
+
96
+ - `Authorization: Basic <base64(user:pass)>` —— 账户密码和 PAT 都能通过(Gitea 会校验用户名与
97
+ 密钥属主一致)。
98
+ - `Authorization: token <secret>` —— 仅对真正的 PAT 有效。
99
+
100
+ 尝试顺序由用户名启发式决定:约定用户名(`oauth2`、`x-oauth-basic`、空)先试 `token`;真实用户名
101
+ (如 `alice`)先试 `basic`。
102
+
103
+ **容错。** 遇到 `401`/`403`,服务端会切换到下一个方案/候选并重试同一请求;一旦某组凭据成功,
104
+ 它会被锁定到本会话结束(不再重复探测)。非认证类错误(`404`、`500`、网络错误)立即向上抛出,
105
+ **不会**触发重试。
106
+
107
+ **诊断。** `gitea_status` 工具(见[仓库辅助](#仓库辅助-repository-helpers))返回当前状态的脱敏
108
+ 视图 —— 哪个候选处于 active、哪些已耗尽、最近一次的状态码 —— 永不暴露密钥本身。排查 `401` 时
109
+ 用它代替盲猜。
110
+
111
+ 若所有来源都未解析到凭据,服务器仍会以匿名方式启动。公开仓库可读;私有仓库和写操作返回
112
+ `401` —— 此时使用 `gitea-configure` 技能引导配置,或设置 `GITEA_TOKEN`。
93
113
 
94
114
  设置 `GITEA_DEFAULT_OWNER` 和 `GITEA_DEFAULT_REPO` 后,调用工具时可以省略
95
115
  `owner` 和 `repo` 参数。也可以使用 `resolve_repo` 工具自动从本地 git 仓库
@@ -227,6 +247,7 @@ gitea-mcp
227
247
  |------|------|
228
248
  | `list_my_repos` | 列出当前用户可访问的仓库 |
229
249
  | `resolve_repo` | 从项目 git 远程地址检测 `baseUrl`、`owner`、`repo`(优先 `upstream`,回退 `origin`) |
250
+ | `gitea_status` | 查看认证处理状态 —— active 候选、已耗尽候选、最近一次错误(脱敏;永不暴露密钥) |
230
251
 
231
252
  ## AI 引导与技能
232
253
 
@@ -279,6 +300,7 @@ npm ci
279
300
  | `make test` | 运行单元测试 |
280
301
  | `make test-watch` | 监听模式运行测试 |
281
302
  | `make test-integration` | 运行集成测试(需要可用的 Gitea 实例) |
303
+ | `make scan` | 用 gitleaks 扫描泄露的密钥(属于 `make verify`) |
282
304
  | `make dev` | 通过 tsx 直接运行 |
283
305
 
284
306
  完整的架构说明(模块布局、依赖关系、核心模式,以及新增工具的指引)请参阅
package/dist/cli.js CHANGED
@@ -13,10 +13,14 @@ if (argv[0] === "init") {
13
13
  });
14
14
  }
15
15
  else {
16
- // Resolve baseUrl/owner/repo/token from env first, then the local git context
17
- // (`.git/config` remotes + credential store). When neither env nor any git
18
- // remote provides a baseUrl, the server is intentionally skipped: a single
19
- // global install should stay dormant outside of git projects.
16
+ // Resolve baseUrl/owner/repo/credentials from env first, then the local git
17
+ // context (`.git/config` remotes + credential store). Discovery collects ALL
18
+ // credential candidates (config token, env token, credential-store entries)
19
+ // rather than picking one, so the client can fall back across them when one
20
+ // scheme is rejected (e.g. an account password that is not a PAT). When
21
+ // neither env nor any git remote provides a baseUrl, the server is
22
+ // intentionally skipped: a single global install should stay dormant outside
23
+ // of git projects.
20
24
  const discovered = await discoverConfig().catch((err) => {
21
25
  console.error("Fatal error:", err);
22
26
  process.exit(1);
@@ -25,7 +29,7 @@ else {
25
29
  console.error(`gitea-mcp: no git remote found in ${process.cwd()} and GITEA_BASE_URL is not set; skipping server start.`);
26
30
  process.exit(0);
27
31
  }
28
- runServer(discovered.baseUrl, discovered.token, discovered.defaultOwner, discovered.defaultRepo).catch((err) => {
32
+ runServer(discovered.baseUrl, discovered.candidates, discovered.defaultOwner, discovered.defaultRepo).catch((err) => {
29
33
  console.error("Fatal error:", err);
30
34
  process.exit(1);
31
35
  });
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAEnC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC;IACvB,6EAA6E;IAC7E,sEAAsE;IACtE,sDAAsD;IACtD,MAAM,EAAE,cAAc,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC;IACvD,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACnD,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC;KAAM,CAAC;IACN,8EAA8E;IAC9E,2EAA2E;IAC3E,2EAA2E;IAC3E,8DAA8D;IAC9D,MAAM,UAAU,GAAG,MAAM,cAAc,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QAC/D,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CACX,qCAAqC,OAAO,CAAC,GAAG,EAAE,wDAAwD,CAC3G,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,SAAS,CACP,UAAU,CAAC,OAAO,EAClB,UAAU,CAAC,KAAK,EAChB,UAAU,CAAC,YAAY,EACvB,UAAU,CAAC,WAAW,CACvB,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACvB,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAEnC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC;IACvB,6EAA6E;IAC7E,sEAAsE;IACtE,sDAAsD;IACtD,MAAM,EAAE,cAAc,EAAE,GAAG,MAAM,MAAM,CAAC,aAAa,CAAC,CAAC;IACvD,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACnD,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC;KAAM,CAAC;IACN,4EAA4E;IAC5E,6EAA6E;IAC7E,4EAA4E;IAC5E,4EAA4E;IAC5E,wEAAwE;IACxE,mEAAmE;IACnE,6EAA6E;IAC7E,mBAAmB;IACnB,MAAM,UAAU,GAAG,MAAM,cAAc,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QAC/D,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CACX,qCAAqC,OAAO,CAAC,GAAG,EAAE,wDAAwD,CAC3G,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IAED,SAAS,CACP,UAAU,CAAC,OAAO,EAClB,UAAU,CAAC,UAAU,EACrB,UAAU,CAAC,YAAY,EACvB,UAAU,CAAC,WAAW,CACvB,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACvB,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;QACnC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Credential candidate model and state machine for fault-tolerant Gitea auth.
3
+ *
4
+ * A discovered credential is treated as an opaque candidate: the `secret`
5
+ * field might be a Gitea Personal Access Token, an account login password,
6
+ * or an OAuth token — the type cannot be determined statically from any
7
+ * source (`~/.git-credentials` stores whatever the user typed into git's
8
+ * password prompt, and `[gitea] token` / `GITEA_TOKEN` are by convention but
9
+ * not guarantee a PAT). The runtime therefore tries each candidate under
10
+ * one or more HTTP auth schemes, advancing on 401/403 until something works
11
+ * or every candidate × scheme is exhausted.
12
+ *
13
+ * This module is pure (no I/O, no fetch) so the state machine is unit-tested
14
+ * directly. Mutation is contained to per-candidate state fields; callers
15
+ * never replace the candidates array.
16
+ *
17
+ * SECURITY: `secret` is never logged, never interpolated into error
18
+ * messages, and never surfaced by `summarizeCandidates`. Diagnostic output
19
+ * only ever carries `secretPresent: boolean` and a masked `username`.
20
+ */
21
+ /** Where a candidate came from. Determines scheme ordering and priority. */
22
+ export type CredentialSource = "gitea-config" | "env" | "credential-store";
23
+ /**
24
+ * HTTP auth scheme. Both are sent via the `Authorization` header.
25
+ * - `token` → `Authorization: token <PAT>` (Gitea's PAT/OAuth-token scheme)
26
+ * - `basic` → `Authorization: Basic base64(<username>:<secret>)` (works for
27
+ * both account passwords and PATs when the username matches the owner)
28
+ */
29
+ export type AuthScheme = "token" | "basic";
30
+ /**
31
+ * A single candidate credential with its runtime state. The state fields
32
+ * (`status`, `nextSchemeIndex`, `lastTriedScheme`, `lastError`,
33
+ * `activeScheme`) are mutated by the GiteaClient as requests succeed or fail.
34
+ */
35
+ export interface CandidateCredential {
36
+ source: CredentialSource;
37
+ /** Username from a credential-store URL. Undefined for config/env sources. */
38
+ username?: string;
39
+ /** The secret value (PAT, password, or OAuth token). Never logged. */
40
+ secret: string;
41
+ /**
42
+ * Ordered list of auth schemes to try for this candidate. The first scheme
43
+ * that succeeds is locked in via `activeScheme` and reused for subsequent
44
+ * requests without re-iterating.
45
+ */
46
+ schemes: AuthScheme[];
47
+ /** Runtime state: pending (untried), active (verified working), exhausted. */
48
+ status: "pending" | "active" | "exhausted";
49
+ /** Index into `schemes` for the next untried scheme. */
50
+ nextSchemeIndex: number;
51
+ /** The scheme that last produced an HTTP error (for diagnostics). */
52
+ lastTriedScheme?: AuthScheme;
53
+ /**
54
+ * The scheme that succeeded and is in active use. Set when status becomes
55
+ * "active"; subsequent requests reuse it without re-iterating.
56
+ */
57
+ activeScheme?: AuthScheme;
58
+ /**
59
+ * Short error reason from the last failed attempt (e.g. "401"). Never
60
+ * contains the secret or the response body.
61
+ */
62
+ lastError?: string;
63
+ }
64
+ /** Result of credential discovery — feeds straight into GiteaClient. */
65
+ export interface CredentialDiscoveryResult {
66
+ baseUrl: string;
67
+ defaultOwner?: string;
68
+ defaultRepo?: string;
69
+ /** Name of the remote the values were derived from. */
70
+ remote?: string;
71
+ /** Candidates in priority order (highest priority first). */
72
+ candidates: CandidateCredential[];
73
+ }
74
+ /** A picked next attempt — the candidate index plus the scheme to apply. */
75
+ export interface Attempt {
76
+ candidateIndex: number;
77
+ scheme: AuthScheme;
78
+ }
79
+ /**
80
+ * Decide scheme ordering for a credential-store entry based on username
81
+ * heuristic. Config/env candidates always use `["token"]` (per project
82
+ * decision to preserve their "simple token" semantics).
83
+ *
84
+ * - Username `oauth2`, `x-oauth-basic`, or empty → `["token", "basic"]`:
85
+ * these are git OAuth conventions; basic auth with such a username fails
86
+ * for real passwords, so try `token` first.
87
+ * - Any other (real-looking) username → `["basic", "token"]`: basic auth
88
+ * with the correct username works for both PATs and passwords, so it has
89
+ * the widest coverage and goes first.
90
+ */
91
+ export declare function orderSchemesForCredentialStore(username?: string): AuthScheme[];
92
+ /**
93
+ * Build the `Authorization` header value for one attempt. Mutates nothing.
94
+ *
95
+ * For `basic`, the username falls back to `oauth2` when absent (a missing
96
+ * username only happens for malformed credential-store entries; Gitea
97
+ * rejects basic auth without a username, so the attempt will fail and the
98
+ * state machine will advance).
99
+ */
100
+ export declare function buildAuthHeader(candidate: CandidateCredential, scheme: AuthScheme): string;
101
+ /**
102
+ * Pick the next (candidate, scheme) to try. Skips exhausted candidates and
103
+ * candidates whose scheme list is fully tried. Does NOT mutate state — the
104
+ * caller records the attempt via `markAttemptFailed` / `markAttemptSucceeded`.
105
+ *
106
+ * Returns null when every candidate × scheme has been tried.
107
+ */
108
+ export declare function pickNextAttempt(candidates: CandidateCredential[]): Attempt | null;
109
+ /**
110
+ * Record that an attempt failed with `error` (a short reason like "401").
111
+ * Advances the candidate's scheme index; when no schemes remain, marks the
112
+ * candidate as exhausted.
113
+ */
114
+ export declare function markAttemptFailed(candidates: CandidateCredential[], candidateIndex: number, error: string): void;
115
+ /**
116
+ * Record that an attempt succeeded. Marks the candidate as active, locks in
117
+ * `activeScheme`, and marks all PRIOR candidates as exhausted (they were
118
+ * tried and failed before this one succeeded).
119
+ */
120
+ export declare function markAttemptSucceeded(candidates: CandidateCredential[], candidateIndex: number, scheme: AuthScheme): void;
121
+ /**
122
+ * Mask a username for diagnostic output: first character plus `***`. Returns
123
+ * null when the candidate has no username (config/env sources, or malformed
124
+ * credential-store entries).
125
+ */
126
+ export declare function maskUsername(username?: string): string | null;
127
+ /**
128
+ * Build a redacted summary of all candidates for the `gitea_status` tool.
129
+ * The `secret` value is never included — only `secretPresent: true`. The
130
+ * caller passes the active candidate index (or null) so the summary can flag
131
+ * which candidate is currently in use.
132
+ */
133
+ export interface CandidateSummary {
134
+ source: CredentialSource;
135
+ schemes: AuthScheme[];
136
+ username: string | null;
137
+ secretPresent: boolean;
138
+ status: "pending" | "active" | "exhausted";
139
+ lastTriedScheme: AuthScheme | null;
140
+ activeScheme: AuthScheme | null;
141
+ lastError: string | null;
142
+ }
143
+ export declare function summarizeCandidates(candidates: CandidateCredential[]): CandidateSummary[];
144
+ /** Find the index of the active candidate, or null when none is active. */
145
+ export declare function findActiveCandidateIndex(candidates: CandidateCredential[]): number | null;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Credential candidate model and state machine for fault-tolerant Gitea auth.
3
+ *
4
+ * A discovered credential is treated as an opaque candidate: the `secret`
5
+ * field might be a Gitea Personal Access Token, an account login password,
6
+ * or an OAuth token — the type cannot be determined statically from any
7
+ * source (`~/.git-credentials` stores whatever the user typed into git's
8
+ * password prompt, and `[gitea] token` / `GITEA_TOKEN` are by convention but
9
+ * not guarantee a PAT). The runtime therefore tries each candidate under
10
+ * one or more HTTP auth schemes, advancing on 401/403 until something works
11
+ * or every candidate × scheme is exhausted.
12
+ *
13
+ * This module is pure (no I/O, no fetch) so the state machine is unit-tested
14
+ * directly. Mutation is contained to per-candidate state fields; callers
15
+ * never replace the candidates array.
16
+ *
17
+ * SECURITY: `secret` is never logged, never interpolated into error
18
+ * messages, and never surfaced by `summarizeCandidates`. Diagnostic output
19
+ * only ever carries `secretPresent: boolean` and a masked `username`.
20
+ */
21
+ /**
22
+ * Decide scheme ordering for a credential-store entry based on username
23
+ * heuristic. Config/env candidates always use `["token"]` (per project
24
+ * decision to preserve their "simple token" semantics).
25
+ *
26
+ * - Username `oauth2`, `x-oauth-basic`, or empty → `["token", "basic"]`:
27
+ * these are git OAuth conventions; basic auth with such a username fails
28
+ * for real passwords, so try `token` first.
29
+ * - Any other (real-looking) username → `["basic", "token"]`: basic auth
30
+ * with the correct username works for both PATs and passwords, so it has
31
+ * the widest coverage and goes first.
32
+ */
33
+ export function orderSchemesForCredentialStore(username) {
34
+ if (username === undefined)
35
+ return ["token", "basic"];
36
+ const u = username.toLowerCase();
37
+ if (u === "oauth2" || u === "x-oauth-basic") {
38
+ return ["token", "basic"];
39
+ }
40
+ return ["basic", "token"];
41
+ }
42
+ /**
43
+ * Build the `Authorization` header value for one attempt. Mutates nothing.
44
+ *
45
+ * For `basic`, the username falls back to `oauth2` when absent (a missing
46
+ * username only happens for malformed credential-store entries; Gitea
47
+ * rejects basic auth without a username, so the attempt will fail and the
48
+ * state machine will advance).
49
+ */
50
+ export function buildAuthHeader(candidate, scheme) {
51
+ if (scheme === "basic") {
52
+ const user = candidate.username ?? "oauth2";
53
+ const encoded = Buffer.from(`${user}:${candidate.secret}`).toString("base64");
54
+ return `Basic ${encoded}`;
55
+ }
56
+ return `token ${candidate.secret}`;
57
+ }
58
+ /**
59
+ * Pick the next (candidate, scheme) to try. Skips exhausted candidates and
60
+ * candidates whose scheme list is fully tried. Does NOT mutate state — the
61
+ * caller records the attempt via `markAttemptFailed` / `markAttemptSucceeded`.
62
+ *
63
+ * Returns null when every candidate × scheme has been tried.
64
+ */
65
+ export function pickNextAttempt(candidates) {
66
+ for (let i = 0; i < candidates.length; i++) {
67
+ const c = candidates[i];
68
+ if (c.status === "exhausted")
69
+ continue;
70
+ // Active candidates are already locked — the caller short-circuits
71
+ // before entering this iteration loop (GiteaClient.request checks
72
+ // findActiveCandidateIndex first). Here we skip them defensively.
73
+ if (c.status === "active")
74
+ continue;
75
+ if (c.nextSchemeIndex >= c.schemes.length)
76
+ continue;
77
+ return { candidateIndex: i, scheme: c.schemes[c.nextSchemeIndex] };
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Record that an attempt failed with `error` (a short reason like "401").
83
+ * Advances the candidate's scheme index; when no schemes remain, marks the
84
+ * candidate as exhausted.
85
+ */
86
+ export function markAttemptFailed(candidates, candidateIndex, error) {
87
+ const c = candidates[candidateIndex];
88
+ c.lastError = error;
89
+ c.lastTriedScheme = c.schemes[c.nextSchemeIndex];
90
+ c.nextSchemeIndex += 1;
91
+ if (c.nextSchemeIndex >= c.schemes.length || c.status === "active") {
92
+ c.status = "exhausted";
93
+ c.activeScheme = undefined;
94
+ }
95
+ }
96
+ /**
97
+ * Record that an attempt succeeded. Marks the candidate as active, locks in
98
+ * `activeScheme`, and marks all PRIOR candidates as exhausted (they were
99
+ * tried and failed before this one succeeded).
100
+ */
101
+ export function markAttemptSucceeded(candidates, candidateIndex, scheme) {
102
+ for (let i = 0; i < candidateIndex; i++) {
103
+ if (candidates[i].status !== "active")
104
+ candidates[i].status = "exhausted";
105
+ }
106
+ const c = candidates[candidateIndex];
107
+ c.status = "active";
108
+ c.activeScheme = scheme;
109
+ c.lastError = undefined;
110
+ }
111
+ /**
112
+ * Mask a username for diagnostic output: first character plus `***`. Returns
113
+ * null when the candidate has no username (config/env sources, or malformed
114
+ * credential-store entries).
115
+ */
116
+ export function maskUsername(username) {
117
+ if (!username)
118
+ return null;
119
+ return `${username.charAt(0)}***`;
120
+ }
121
+ export function summarizeCandidates(candidates) {
122
+ return candidates.map((c) => ({
123
+ source: c.source,
124
+ schemes: c.schemes,
125
+ username: maskUsername(c.username),
126
+ secretPresent: c.secret.length > 0,
127
+ status: c.status,
128
+ lastTriedScheme: c.lastTriedScheme ?? null,
129
+ activeScheme: c.activeScheme ?? null,
130
+ lastError: c.lastError ?? null,
131
+ }));
132
+ }
133
+ /** Find the index of the active candidate, or null when none is active. */
134
+ export function findActiveCandidateIndex(candidates) {
135
+ for (let i = 0; i < candidates.length; i++) {
136
+ if (candidates[i].status === "active")
137
+ return i;
138
+ }
139
+ return null;
140
+ }
141
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { type CredentialDiscoveryResult } from "./credentials.js";
1
2
  /** A single parsed git remote. `remote` is the remote name (`origin`, `upstream`, ...). */
2
3
  export interface ParsedRemote {
3
4
  remote: string;
@@ -18,15 +19,22 @@ export interface DiscoverOptions {
18
19
  /** Override credential-store paths (defaults to XDG then `~/.git-credentials`). */
19
20
  credentialsPaths?: string[];
20
21
  }
21
- export interface DiscoveredConfig {
22
- baseUrl: string;
23
- /** Undefined when no token source resolves — the server still starts and a Skill guides the user. */
24
- token?: string;
25
- defaultOwner?: string;
26
- defaultRepo?: string;
27
- /** Name of the remote the values were derived from (undefined when derived purely from env). */
28
- remote?: string;
29
- source: "env" | "git";
22
+ /**
23
+ * A parsed `~/.git-credentials` line. Both `username` and `password` are
24
+ * URL-decoded. Either may be absent:
25
+ * - `https://user:pass@host` → both present
26
+ * - `https://:pass@host` → only password
27
+ * - `https://tok@host` → only username (git stores a token here when the
28
+ * host accepts one as the "username"; the caller treats it as the secret)
29
+ *
30
+ * `path` is the URL pathname with leading/trailing slashes and any `.git`
31
+ * suffix stripped, used to narrow multiple host matches toward the target repo.
32
+ */
33
+ export interface ParsedCredentialEntry {
34
+ username?: string;
35
+ password?: string;
36
+ host: string;
37
+ path: string;
30
38
  }
31
39
  /**
32
40
  * Parse a git remote URL into host/baseUrl/owner/repo. Accepts `ssh://`, the
@@ -48,11 +56,18 @@ export declare function selectRemote(remotes: ParsedRemote[]): ParsedRemote | nu
48
56
  */
49
57
  export declare function readTokenFromGitConfig(content: string, baseUrl: string): string | undefined;
50
58
  /**
51
- * Find a credential for `host` in a `git-credentials` file. Each line is a URL;
52
- * the password (preferred) or username is returned as the token. Malformed and
53
- * non-matching lines are skipped.
59
+ * Parse every credential-store line whose host matches. Each line is a URL of
60
+ * the form `protocol://[user[:pass]]@host[:port][/path]`; malformed and
61
+ * non-matching lines are skipped. Returns entries in file order; callers
62
+ * narrow and re-sort by repo path specificity.
63
+ *
64
+ * The `password` field — when present — holds whatever the user typed into
65
+ * git's password prompt: a real account password, a Personal Access Token,
66
+ * or an OAuth token. Git itself does not distinguish, and neither does this
67
+ * parser; the GiteaClient runtime tries each entry under multiple auth
68
+ * schemes to discover what works.
54
69
  */
55
- export declare function parseGitCredentials(content: string, host: string): string | undefined;
70
+ export declare function parseGitCredentials(content: string, host: string): ParsedCredentialEntry[];
56
71
  /** Default credential-store paths: `$XDG_CONFIG_HOME/git/credentials` then `~/.git-credentials`. */
57
72
  export declare function defaultCredentialsPaths(): string[];
58
73
  /**
@@ -62,12 +77,21 @@ export declare function defaultCredentialsPaths(): string[];
62
77
  * remote (`upstream` → `origin` → first). Returns null only when neither is
63
78
  * available — callers should treat that as "do not start the server".
64
79
  *
65
- * token: `.git/config` `[gitea "<baseUrl>"] token` → bare `[gitea] token` →
66
- * matching entry in a git credential store `GITEA_TOKEN` (env). May be
67
- * undefined when nothing resolves; the server still starts and a Skill
68
- * guides the user to provide one.
80
+ * candidates (in priority order):
81
+ * 1. `[gitea "<baseUrl>"] token` / bare `[gitea] token` from `.git/config`
82
+ * (explicit user configuration; `token` scheme only).
83
+ * 2. `GITEA_TOKEN` env var (explicit env; `token` scheme only — preserves
84
+ * the simple-token semantics).
85
+ * 3. Every host-matching entry in a git credential store, narrowed by repo
86
+ * path specificity (most specific first). Each entry may be a PAT,
87
+ * password, or OAuth token; the client tries each under `basic` and/or
88
+ * `token` schemes per the username heuristic.
69
89
  *
70
90
  * owner/repo: `GITEA_DEFAULT_OWNER`/`GITEA_DEFAULT_REPO` (env) win; otherwise
71
- * taken from the selected remote.
91
+ * taken from the selected remote.
92
+ *
93
+ * The result may have an empty `candidates` array (anonymous mode); the server
94
+ * still starts and a Skill guides the user to provide one. Write tools will
95
+ * fail with 401/403 until a working credential is added.
72
96
  */
73
- export declare function discoverConfig(options?: DiscoverOptions): Promise<DiscoveredConfig | null>;
97
+ export declare function discoverConfig(options?: DiscoverOptions): Promise<CredentialDiscoveryResult | null>;