diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f7326aa..bd169e40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,11 @@ All notable changes to this project will be documented in this file. See [standa - Multi-project management: `role` and `project` together resolve resource namespaces, and project-private learnings are isolated ([#426](https://github.com/Tencent/teamai-cli/pull/426), for [#375](https://github.com/Tencent/teamai-cli/issues/375)). - Data partitions auto-migrate a legacy `.teamai`, resume interrupted migrations, smoke-check the clone, and keep a git-ignored backup ([#439](https://github.com/Tencent/teamai-cli/pull/439), for [#374](https://github.com/Tencent/teamai-cli/issues/374)). - Teams add their own course-correction words via `sharing.intervention.correctionKeywords` in `teamai.yaml`. The built-in list still covers only Chinese, English and Japanese, so corrections typed in other languages count only once the team configures them. The `UserPromptSubmit` hook now stores a `correction` flag on each dashboard prompt event (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). +- `teamai init --provider ` uses the named provider instead of detecting one, so a member of a team on self-hosted GitLab can join with `--provider git` and their existing Git authentication, without `GITLAB_TOKEN`. The choice is saved in that machine's local config and takes precedence over the team's `teamai.yaml` `provider` for PR/MR creation and for `teamai doctor`'s provider checks; an existing `teamai.yaml` is unchanged, so other members keep the team's provider, and a `teamai.yaml` that `init` creates records the provider `init` would detect without the flag rather than `git`, and stops with a `GITLAB_URL` hint on an unconfigured self-hosted GitLab. `--provider gitlab` on a host that is not the configured `GITLAB_URL` or `TEAMAI_GITLAB_HOST` stops with a hint instead of sending the token to gitlab.com. With `git`, `teamai push` pushes the branch and leaves the merge request to be opened on the Git host, exiting non-zero as it does for a `provider: git` team repo. Re-running `init` without `--provider` returns to auto-detection. The value must be one of `tgit`, `github`, `cnb`, `gitlab`, `gitcode` or `git`, and `--provider` cannot be combined with `--http` (for [#789](https://github.com/Tencent/teamai-cli/issues/789)). ### 🐛 Bug Fixes +- When `TEAMAI_GITLAB_HOST` and `GITLAB_URL` name different hosts, GitLab commands stop with an error naming both, before any request. A repo on `TEAMAI_GITLAB_HOST` was detected as GitLab while every API call, the token included, went to `GITLAB_URL`. An invalid `GITLAB_URL` is now reported as such by `init` instead of as a failed GitLab login (for [#789](https://github.com/Tencent/teamai-cli/issues/789)). - A misspelled top-level key in `mcp/mcp.yaml` or `hooks/hooks.yaml` (`server:` for `servers:`, `hook:` for `hooks:`) no longer removes every installed team MCP server or hook: such a file read as empty. It now fails like a file that does not parse, so pull keeps what is installed, and pull and `teamai doctor` name the file, the keys found and the key expected. An extra top-level key beside `servers:` or `hooks:` is still ignored (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - The closing line of `teamai recall` output is in English (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - A broken team file no longer wipes or downgrades what a member has installed. An `mcp/mcp.yaml` or `hooks/hooks.yaml` that did not parse reconciled to an empty set and removed every team MCP server or hook from every tool, and two active namespaces defining one skill or agent aborted the pull for the whole scope, skipping rules, env, docs and cleanup. Now a file in the active set that does not parse or cannot be read, a name repeated inside one file, or one name in two active namespaces stops only that resource type for the run: env keeps `env.sh`, hooks and MCP keep their entries (the built-in hooks, with the session-start pull, are still installed where missing: with the root hooks file's `builtin:` overrides when it parses, and otherwise with their defaults only in a tool that has none yet; `teamai init` says the team hooks were not installed), model profiles leave switched agents alone, skills and agents keep what is installed, and every other type still syncs. The warning names the file or both files and the fix, and for env, hooks, MCP and models is also written to `~/.teamai/debug.log` for session-start pulls (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/providers.md b/docs/providers.md index d2f63dadf..f33639db2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -33,7 +33,15 @@ git@git.example.com:group/repo.git → 检查 GitLab,未确认则 git 已知 host 和显式配置的 GitLab 实例优先。对于未知 host,`init` 会匿名探测 GitLab 登录页;确认是未配置的 GitLab 实例时,先提示设置 `GITLAB_URL` 和 `GITLAB_TOKEN` 后重试,不会直接把探测结果写入配置。未确认则继续使用 `git`。 -初始化成功后,provider 选择会写入 team 仓库的 `teamai.yaml` 的 `provider` 字段,后续 `push` / `pull` 都按这个值来。探测不会自动修改已有的 provider。 +初始化成功后,provider 选择会写入 team 仓库的 `teamai.yaml` 的 `provider` 字段,后续 `push` / `pull` 都按这个值来;成员用 `--provider` 保存在本机的选择优先于它(见下节)。探测不会自动修改已有的 provider。 + +### 手动指定 provider(`--provider`) + +`teamai init --provider ` 跳过上面的自动检测(包括 GitLab 探测),直接使用指定的 provider,取值与 `teamai.yaml` 的 `provider` 相同:`tgit`、`github`、`cnb`、`gitlab`、`gitcode`、`git`。典型用法是团队仓库在自建 GitLab 上、但成员只需要普通 Git:`--provider git` 不做平台登录、不检查 `GITLAB_TOKEN`,clone/pull/push 走已有的 Git 凭据。 + +该选择写入成员本机的本地配置(`provider` 字段),只影响这台机器:创建 PR/MR(`push`、`remove` 等)和 `doctor` 的 provider 检查优先使用它,已有的 `teamai.yaml` 不变。`init` 新建 `teamai.yaml`(空仓库,或单仓库模式首次初始化)时,`--provider git` 写入的仍是不带该参数时检测到的 provider(包括 GitLab 探测);探测到尚未配置的自建 GitLab 时 `init` 会停止并提示设置 `GITLAB_URL`,不会把 `git` 写成团队默认值。其他值按指定值写入。不带 `--provider` 重新运行 `init` 即恢复自动检测。 + +自建 GitLab 使用 `--provider gitlab` 时仍需设置 `GITLAB_URL`(以及 `GITLAB_TOKEN`)。GitLab API 地址取自 `GITLAB_URL`,未设置时指向 gitlab.com,所以检测无法识别该 host 时 `init` 会直接报错退出,不会把 token 发往别处。 ## 通用 Git Provider(自建/私有仓库) @@ -229,7 +237,7 @@ export GITLAB_TOKEN=glpat-xxx ### 自托管实例检测 - **公有 gitlab.com**:URL host 直接命中,自动选择 gitlab provider。 -- **自托管实例**:设置 `GITLAB_URL` 后,URL host 与 `GITLAB_URL` 的 host 相同时自动识别为 gitlab;也可用 `TEAMAI_GITLAB_HOST` 直接指定 host。仓库参数需使用完整 HTTP(S) 或 SSH URL: +- **自托管实例**:设置 `GITLAB_URL` 后,URL host 与 `GITLAB_URL` 的 host 相同时自动识别为 gitlab;也可用 `TEAMAI_GITLAB_HOST` 直接指定 host,未设 `GITLAB_URL` 时 API 指向 `https://<该 host>`。两者同时设置但 host 不同时,teamai 会在发送 token 前报错停止。仓库参数需使用完整 HTTP(S) 或 SSH URL: ```bash export GITLAB_URL=https://git.example.com teamai init https://git.example.com/yourgroup/yourrepo # → gitlab diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 04714a3ca..19576861e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -112,8 +112,12 @@ export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxx teamai init https://git.example.com/yourgroup/yourrepo ``` +`TEAMAI_GITLAB_HOST=git.example.com` also works without `GITLAB_URL`: the API then goes to `https://git.example.com`. When both are set they must name the same host, otherwise teamai stops before sending the token. + For an unknown host, `init` makes an anonymous GitLab sign-in page check with a three-second total timeout. If confirmed as GitLab, it stops before authentication, cloning, or writing configuration and asks you to set the instance URL and token, then retry. The check does not send tokens or follow redirects. If it cannot confirm GitLab, initialization continues with the generic `git` provider, which supports Git transport but cannot create repos or PRs/MRs automatically. Set `GITLAB_URL` explicitly for instances behind SSO, deployed under a subpath, or otherwise inaccessible to the check. +Members who only sync and never need the CLI to open merge requests can skip the token: see [plain Git with `--provider git`](#member-onboarding). + **Already initialized with `provider: git`?** Set the variables above and change `provider` to `gitlab` in the team repo's `teamai.yaml`. Setting the environment variables alone does not change an existing provider selection. A failed `teamai push` may already have pushed the branch; if its diagnostic detects GitLab, it prints these recovery steps. See [provider configuration](providers.md#gitlab-provider含自托管). ### Project Scope (default) @@ -541,6 +545,20 @@ npm install -g teamai-cli teamai init https://github.com/yourorg/yourrepo --scope user ``` +**Plain Git, no platform token (`--provider git`):** + +When the team repo is on a platform whose provider needs a token (for example self-hosted GitLab and `GITLAB_TOKEN`), a member who never needs the CLI to open PRs/MRs can use their existing Git authentication (SSH key or credential helper) instead: + +```bash +teamai init https://gitlab.example.com/yourgroup/yourrepo --provider git +``` + +- `--provider` skips auto-detection and uses the named provider: `tgit`, `github`, `cnb`, `gitlab`, `gitcode`, or `git`. `git` runs no platform login or token check. +- The choice is saved in this machine's local config only. An existing `teamai.yaml` is not changed, so other members keep the team's provider. When `init` creates a new `teamai.yaml`, `--provider git` still records the provider `init` would detect without it. If the host is a self-hosted GitLab that is not configured, `init` stops and asks for `GITLAB_URL` rather than record `git` as the team default. +- `--provider gitlab` on a self-hosted instance still needs `GITLAB_URL` or `TEAMAI_GITLAB_HOST` (and `GITLAB_TOKEN`). Without either `init` stops, because the GitLab API would otherwise target gitlab.com. +- `pull` works as usual. `push` pushes the branch but cannot open a PR/MR, so open it on the Git host yourself; the command exits non-zero because that step did not run. +- Re-running `teamai init` without `--provider` returns to auto-detection. + **HTTP mode (read-only consumer):** For users or agents that don't need git access and only consume skills/rules: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 05281d772..7b081a3ab 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -111,8 +111,12 @@ export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxx teamai init https://git.example.com/yourgroup/yourrepo ``` +也可以不设 `GITLAB_URL`,只设 `TEAMAI_GITLAB_HOST=git.example.com`:此时 API 指向 `https://git.example.com`。两者同时设置时必须是同一个 host,否则 teamai 会在发送 token 前停止。 + 对于未知 host,`init` 会匿名检查 GitLab 登录页,总超时为三秒。确认是 GitLab 后,会在认证、克隆或写入配置前停止,提示设置实例地址和 token 后重试。探测不发送 token,也不跟随重定向。无法确认时,初始化继续使用通用 `git` provider;它支持 Git 传输,但不能自动建仓或创建 PR/MR。实例若由 SSO 遮蔽、部署在子路径下,或无法被探测访问,请显式设置 `GITLAB_URL`。 +只同步资源、从不需要 CLI 创建 MR 的成员可以不配 token:见[成员接入](#成员接入)中的 `--provider git`。 + **已经初始化为 `provider: git`?** 设置上述环境变量,并把团队仓库 `teamai.yaml` 中的 `provider` 改为 `gitlab`。仅设置环境变量不会改变已有 provider 选择。失败的 `teamai push` 可能已经推送了分支;若其诊断探测到 GitLab,会输出这些修复步骤。详见 [Provider 配置](providers.md#gitlab-provider含自托管)。 ### 项目级(Project Scope,默认) @@ -482,6 +486,20 @@ npm install -g teamai-cli teamai init https://github.com/yourorg/yourrepo --scope user ``` +**纯 Git、无需平台 token(`--provider git`):** + +团队仓库所在平台的 provider 需要 token 时(例如自建 GitLab 需要 `GITLAB_TOKEN`),从不需要 CLI 创建 PR/MR 的成员可以改用已有的 Git 认证(SSH Key 或 Credential Helper): + +```bash +teamai init https://gitlab.example.com/yourgroup/yourrepo --provider git +``` + +- `--provider` 跳过自动检测,直接使用指定的 provider:`tgit`、`github`、`cnb`、`gitlab`、`gitcode` 或 `git`。`git` 不做平台登录,也不检查 token。 +- 该选择只保存在本机的本地配置中。已有的 `teamai.yaml` 不变,其他成员仍使用团队的 provider。`init` 新建 `teamai.yaml` 时,`--provider git` 写入的仍是 `init` 不带该参数时检测到的 provider;若 host 是尚未配置的自建 GitLab,`init` 会停止并提示设置 `GITLAB_URL`,而不是写入 `git`。 +- 自建 GitLab 使用 `--provider gitlab` 时仍需设置 `GITLAB_URL` 或 `TEAMAI_GITLAB_HOST`(以及 `GITLAB_TOKEN`)。两者都未设置时 `init` 会直接停止,否则 GitLab API 会指向 gitlab.com。 +- `pull` 照常工作。`push` 会推送分支,但无法创建 PR/MR,需要到 Git 平台上手动创建;由于这一步没有完成,命令以非零退出码结束。 +- 不带 `--provider` 重新运行 `teamai init` 即恢复自动检测。 + **HTTP 模式(只读消费者):** 无需 git 访问、仅消费 skills/rules 的用户或 agent: diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index d6a62a0b3..0b3dd145b 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -20,6 +20,7 @@ Generated: do not edit by hand. Regenerate with - `teamai init [repo]` — Initialize teamai (configure Git provider, clone repo, register member) - `--repo ` — Team repo (alias of the positional argument) - `--http ` — Git-free HTTP team repo (read-only consumer; only needs an API key) + - `--provider ` — Git provider for the team repo on this machine: tgit, github, cnb, gitlab, gitcode, or git. Skips auto-detection. `git` uses your existing Git auth and needs no platform token, but opens no PR/MR. - `--self` — Single-repo mode: the current git repo is the team repo (equivalent to `teamai init .`). Knowledge lives on main under .teamai/; reports go to the teamai-reports orphan branch. - `--token ` — API key for HTTP team repo / status reporting (stored 0600, never committed). Also reads TEAMAI_API_TOKEN. - `--scope ` — Install scope: project (default, /.teamai + /.claude) or user (~/.teamai + ~/.claude) diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index ef91edcfa..89eafa8ea 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -87,6 +87,9 @@ read -rs GITLAB_TOKEN && export GITLAB_TOKEN # paste when prompted; api scope teamai init https://git.example.com/yourgroup/yourrepo ``` +A member who only syncs and never needs the CLI to open merge requests can skip +both: `teamai init --provider git` uses their existing Git authentication. + ## Which tools actually get hooks `teamai hooks inject` prints **"Hooks injected into all AI tool settings"** even diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 3b51da049..ee01c186b 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -58,7 +58,11 @@ Match the login to the URL's host (do NOT create a second repo): before continuing. (Headless/CI only: pre-set `GITHUB_TOKEN` — a token with `repo` scope — instead.) - **`gitlab.com/...`** or self-hosted GitLab → set `GITLAB_TOKEN` (and `GITLAB_URL` - for self-hosted, with `api` scope) + for self-hosted, with `api` scope). **Exception:** a member who only syncs and + never needs the CLI to open merge requests (typical for non-developers) can skip + the token: add `--provider git` to the `init` in Step 4. Git then uses their + existing SSH key or credential helper, and `push` leaves the MR for them to open + on the web. If they have no account on that platform, they register there, then ask the admin to add them to the repo. diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index 66a42405d..50083d36d 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -501,6 +501,19 @@ describe('doctor — hook checks', () => { expect(allPassed).toBe(false); }); + // #789: a member on `init --provider git` is not asked for the team + // provider's CLI or token. + it('checks the member\'s provider instead of the team\'s', async () => { + mockedLoadLocalConfig.mockResolvedValue({ ...mockLocalConfig, provider: 'git' }); + + await doctor({}); + + const allLines = consoleSpy.mock.calls.map((c) => String(c[0])); + expect(allLines.some((line) => line.includes('gf CLI'))).toBe(false); + expect(mockedIsGfInstalled).not.toHaveBeenCalled(); + expect(mockedGfIsAuthenticated).not.toHaveBeenCalled(); + }); + it('checks hooks only for enabled agents', async () => { mockedLoadLocalConfig.mockResolvedValue({ ...mockLocalConfig, diff --git a/src/__tests__/gitlab-detection-e2e.test.ts b/src/__tests__/gitlab-detection-e2e.test.ts index 00833c0c7..a7a8951f1 100644 --- a/src/__tests__/gitlab-detection-e2e.test.ts +++ b/src/__tests__/gitlab-detection-e2e.test.ts @@ -101,3 +101,56 @@ describe('self-hosted GitLab detection through the built CLI', () => { expect(requests).toHaveLength(before); }); }); + +describe('GitLab token requests through the built CLI stay on the repository host', () => { + let sandbox: string; + let stub: string; + + beforeAll(() => { + expect(fs.existsSync(CLI), 'Run npm run build first').toBe(true); + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitlab-host-')); + // Preloaded into the CLI: records each request host and answers 401, so no + // request leaves the machine. + stub = path.join(sandbox, 'stub-fetch.mjs'); + fs.writeFileSync(stub, [ + "import fs from 'node:fs';", + 'globalThis.fetch = async (input) => {', + " fs.appendFileSync(process.env.FETCH_LOG, new URL(String(input)).host + '\\n');", + " return new Response('{}', { status: 401 });", + '};', + ].join('\n')); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + async function runInit(extraArgs: string[], env: Record) { + const dir = fs.mkdtempSync(path.join(sandbox, 'run-')); + const log = path.join(dir, 'fetch.log'); + const result = await runCLI( + ['init', 'https://gitlab.corp/team/repo.git', '--agent', 'claude', '--force', ...extraArgs], + dir, dir, { NODE_OPTIONS: `--import=${stub}`, FETCH_LOG: log, GITLAB_TOKEN: 'corp-token', ...env }, + ); + const hosts = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n') : []; + return { ...result, hosts }; + } + + for (const [label, extraArgs] of [['auto-detected', []], ['--provider gitlab', ['--provider', 'gitlab']]] as const) { + it(`${label}: TEAMAI_GITLAB_HOST without GITLAB_URL sends the token only to that host`, async () => { + const result = await runInit([...extraArgs], { TEAMAI_GITLAB_HOST: 'gitlab.corp' }); + expect(result.hosts.length, result.output).toBeGreaterThan(0); + expect(new Set(result.hosts)).toEqual(new Set(['gitlab.corp'])); + }); + + it(`${label}: TEAMAI_GITLAB_HOST and GITLAB_URL naming different hosts sends no request`, async () => { + const result = await runInit([...extraArgs], { + TEAMAI_GITLAB_HOST: 'gitlab.corp', GITLAB_URL: 'https://gitlab.com', + }); + expect(result.code, result.output).toBe(1); + expect(result.hosts).toEqual([]); + expect(result.output).toContain('TEAMAI_GITLAB_HOST'); + expect(result.output).not.toContain('corp-token'); + }); + } +}); diff --git a/src/__tests__/gitlab-provider.test.ts b/src/__tests__/gitlab-provider.test.ts index 345b3d282..4327aa387 100644 --- a/src/__tests__/gitlab-provider.test.ts +++ b/src/__tests__/gitlab-provider.test.ts @@ -290,6 +290,15 @@ describe('gitlabWhoami', () => { expect(await gitlabWhoami()).toBeNull(); }); + it('reports a GitLab URL configuration error instead of a failed login, without a request', async () => { + process.env.GITLAB_TOKEN = 'glpat_test'; + process.env.TEAMAI_GITLAB_HOST = 'gitlab.corp'; + process.env.GITLAB_URL = 'https://gitlab.com'; + global.fetch = vi.fn() as never; + await expect(gitlabWhoami()).rejects.toThrow(/name different GitLab hosts/); + expect(global.fetch).not.toHaveBeenCalled(); + }); + it('returns null when no token is set', async () => { delete process.env.GITLAB_TOKEN; delete process.env.GITLAB_PRIVATE_TOKEN; @@ -523,6 +532,20 @@ describe('gitlabBaseUrl', () => { process.env.GITLAB_URL = 'gitlab.example.com'; expect(() => gitlabBaseUrl()).toThrow(/Invalid GITLAB_URL/); }); + + it('refuses a TEAMAI_GITLAB_HOST that names a different host than GITLAB_URL', () => { + process.env.TEAMAI_GITLAB_HOST = 'gitlab.corp'; + process.env.GITLAB_URL = 'https://gitlab.com'; + expect(() => gitlabBaseUrl()).toThrow(/TEAMAI_GITLAB_HOST \(gitlab\.corp\) and GITLAB_URL \(https:\/\/gitlab\.com\)/); + }); + + it('accepts a TEAMAI_GITLAB_HOST that matches GITLAB_URL with or without its port', () => { + process.env.GITLAB_URL = 'https://gitlab.corp:8443/gitlab'; + for (const host of ['gitlab.corp:8443', 'GITLAB.corp']) { + process.env.TEAMAI_GITLAB_HOST = host; + expect(gitlabBaseUrl()).toBe('https://gitlab.corp:8443/gitlab'); + } + }); }); describe('getGitLabToken — blank handling', () => { diff --git a/src/__tests__/gitlab-push-guidance.test.ts b/src/__tests__/gitlab-push-guidance.test.ts index b31dec81e..40d53fa79 100644 --- a/src/__tests__/gitlab-push-guidance.test.ts +++ b/src/__tests__/gitlab-push-guidance.test.ts @@ -78,6 +78,23 @@ describe('GitLab guidance after generic Git PR creation fails', () => { expect(process.env.GITLAB_URL).toBe('https://private-code.example.test'); }); + // #789: the member chose plain git with `init --provider git`; the team's + // teamai.yaml still says gitlab and must not be used for the PR step. + it('uses the member\'s git provider over the team\'s gitlab and asks for no token', async () => { + vi.stubEnv('GITLAB_URL', 'https://private-code.example.test'); + const teamConfig = { repo: REMOTE, provider: 'gitlab' }; + const memberConfig = { ...localConfig, provider: 'git' }; + + await expect(createPrWithFallback(teamConfig, memberConfig, BRANCH, 'Title', 'Body')).resolves.toBeNull(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(fail).toHaveBeenCalledWith(UNSUPPORTED_PR); + expect(log.info).toHaveBeenCalledWith(`Branch ${BRANCH} has been pushed. You can create a PR manually.`); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('teamai init --provider git')); + expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining('GITLAB_TOKEN')); + expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining('Change it to provider: gitlab')); + }); + it('preserves the original failure and manual PR guidance when an unknown host is not GitLab', async () => { const teamConfig = { repo: REMOTE, provider: 'git' }; const originalConfig = structuredClone(teamConfig); diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 5c6d35696..af1a39680 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -231,7 +231,9 @@ vi.mock('../utils/prompt.js', () => ({ // Prevent process.exit from actually exiting const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); -import { init } from '../init.js'; +import { init, resolveInitProvider } from '../init.js'; +import { GenericGitProvider } from '../providers/git/index.js'; +import { GitLabProvider } from '../providers/gitlab/index.js'; import { RepoNotFoundError, RepoCreatePermissionError } from '../providers/types.js'; import { CnbRepoNotFoundError } from '../providers/cnb/cnb-cli.js'; import { saveLocalConfig, loadLocalConfigForScope } from '../config.js'; @@ -1125,3 +1127,239 @@ describe('init', () => { }); }); }); + +// #789: a member of a GitLab team who only needs plain git picks the provider +// instead of letting init detect GitLab and ask for GITLAB_TOKEN. +describe('init --provider', () => { + const HOME = process.env.HOME ?? ''; + const localPath = `${HOME}/.teamai/team-repo`; + const GITLAB_REPO = 'https://gitlab.example.test/group/team-repo.git'; + const fetchMock = vi.fn(); + const TEAM_CONFIG = { + team: 'team-repo', + description: '', + repo: GITLAB_REPO, + provider: 'gitlab', + reviewers: [], + sharing: { rules: { enforced: [] }, docs: {}, env: { injectShellProfile: true } }, + toolPaths: {}, + } as never; + // What an unconfigured self-hosted GitLab answers to init's probe. + const gitlabSignInResponse = () => new Response('', { + status: 200, + headers: { 'x-gitlab-meta': JSON.stringify({ correlation_id: 'c1', version: '1' }) }, + }); + let cloned = false; + let spies: Array<{ mockRestore: () => void }> = []; + + beforeEach(() => { + vi.clearAllMocks(); + questionAnswers = ['n']; + cloned = false; + pathExistsFn = (p: string) => p === localPath && cloned; + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('GITLAB_URL', ''); + vi.stubEnv('TEAMAI_GITLAB_HOST', ''); + vi.stubEnv('GITLAB_TOKEN', ''); + // Restored one by one: vi.restoreAllMocks would also restore the + // process.exit spy the whole file relies on. + spies = [ + vi.spyOn(GenericGitProvider.prototype, 'authenticate').mockResolvedValue('plain-member'), + vi.spyOn(GenericGitProvider.prototype, 'cloneRepo').mockImplementation(() => { cloned = true; }), + vi.spyOn(GitLabProvider.prototype, 'isAuthenticated'), + vi.spyOn(GitLabProvider.prototype, 'authenticate'), + ]; + }); + + afterEach(() => { + for (const spy of spies) spy.mockRestore(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + mockExit.mockClear(); + }); + + it('accepts every provider name the team config accepts', () => { + for (const name of ['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'git']) { + expect(resolveInitProvider(name)).toBe(name); + } + expect(resolveInitProvider(undefined)).toBeUndefined(); + }); + + it('rejects an unknown provider and names the valid ones', () => { + expect(() => resolveInitProvider('GitLab')).toThrow( + 'Invalid --provider "GitLab". Use one of: tgit, github, cnb, gitlab, gitcode, git, ' + + 'or omit --provider to detect it from the repo URL.', + ); + }); + + it('stops before any provider or repository side effect when the provider is unknown', async () => { + const { log } = await import('../utils/logger.js'); + + await init({ repo: GITLAB_REPO, provider: 'gitlabb', scope: 'user' }); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Invalid --provider "gitlabb"')); + expect(GenericGitProvider.prototype.cloneRepo).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses --provider with an HTTP team repo, which has no git provider', async () => { + const { log } = await import('../utils/logger.js'); + + await init({ http: 'https://teamai.example.test', provider: 'git', scope: 'user', role: 'hai' }); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--provider cannot be combined with --http')); + }); + + it('uses plain git for a configured self-hosted GitLab without asking for a token', async () => { + vi.stubEnv('GITLAB_URL', 'https://gitlab.example.test'); + + await init({ repo: GITLAB_REPO, provider: 'git', scope: 'user', role: 'hai' }); + + expect(mockExit).not.toHaveBeenCalled(); + expect(GitLabProvider.prototype.isAuthenticated).not.toHaveBeenCalled(); + expect(GitLabProvider.prototype.authenticate).not.toHaveBeenCalled(); + expect(GenericGitProvider.prototype.cloneRepo).toHaveBeenCalledWith(GITLAB_REPO, localPath); + expect(saveLocalConfig).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'git', username: 'plain-member' }), + ); + }); + + it('does not probe an unconfigured host that would be detected as GitLab', async () => { + const { loadTeamConfig } = await import('../config.js'); + // Joining a team repo that already has teamai.yaml: nothing team-wide is written. + vi.mocked(loadTeamConfig).mockResolvedValue(TEAM_CONFIG); + + await init({ repo: GITLAB_REPO, provider: 'git', scope: 'user', role: 'hai' }); + + expect(mockExit).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(saveLocalConfig).toHaveBeenCalledWith(expect.objectContaining({ provider: 'git' })); + }); + + it('refuses --provider gitlab for a host with no configured GitLab instance', async () => { + // The GitLab API would default to gitlab.com and receive this host's token. + vi.stubEnv('GITLAB_TOKEN', 'company-token'); + const { log } = await import('../utils/logger.js'); + + await init({ repo: GITLAB_REPO, provider: 'gitlab', scope: 'user', role: 'hai' }); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Set GITLAB_URL')); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('--provider git')); + expect(GitLabProvider.prototype.isAuthenticated).not.toHaveBeenCalled(); + expect(GitLabProvider.prototype.authenticate).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('accepts --provider gitlab once the instance is configured', async () => { + vi.stubEnv('GITLAB_URL', 'https://gitlab.example.test'); + spies.push( + vi.spyOn(GitLabProvider.prototype, 'ensureInstalled').mockResolvedValue(undefined), + vi.spyOn(GitLabProvider.prototype, 'cloneRepo').mockImplementation(() => { cloned = true; }), + ); + vi.mocked(GitLabProvider.prototype.isAuthenticated).mockReturnValue(true); + vi.mocked(GitLabProvider.prototype.authenticate).mockResolvedValue('gitlab-member'); + + await init({ repo: GITLAB_REPO, provider: 'gitlab', scope: 'user', role: 'hai' }); + + expect(mockExit).not.toHaveBeenCalled(); + expect(saveLocalConfig).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'gitlab', username: 'gitlab-member' }), + ); + }); + + it('writes the team provider, not the member\'s git override, into a new teamai.yaml', async () => { + vi.stubEnv('GITLAB_URL', 'https://gitlab.example.test'); + const { loadTeamConfig } = await import('../config.js'); + const { writeFile } = await import('../utils/fs.js'); + vi.mocked(loadTeamConfig).mockResolvedValue(null); + + await init({ repo: GITLAB_REPO, provider: 'git', scope: 'user', role: 'hai' }); + + const teamYaml = vi.mocked(writeFile).mock.calls.find(([p]) => String(p).endsWith('teamai.yaml')); + expect(teamYaml?.[1]).toContain('"provider":"gitlab"'); + expect(saveLocalConfig).toHaveBeenCalledWith(expect.objectContaining({ provider: 'git' })); + }); + + it('refuses to create teamai.yaml on an unconfigured self-hosted GitLab instead of recording git', async () => { + fetchMock.mockResolvedValue(gitlabSignInResponse()); + const { loadTeamConfig } = await import('../config.js'); + const { writeFile } = await import('../utils/fs.js'); + const { log } = await import('../utils/logger.js'); + vi.mocked(loadTeamConfig).mockResolvedValue(null); + + await init({ repo: GITLAB_REPO, provider: 'git', scope: 'user', role: 'hai' }); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Set GITLAB_URL=https://gitlab.example.test')); + expect(vi.mocked(writeFile).mock.calls.find(([p]) => String(p).endsWith('teamai.yaml'))).toBeUndefined(); + expect(saveLocalConfig).not.toHaveBeenCalled(); + }); + + it('records git in a new teamai.yaml when the host is not a GitLab', async () => { + fetchMock.mockResolvedValue(new Response('not found', { status: 404 })); + const { loadTeamConfig } = await import('../config.js'); + const { writeFile } = await import('../utils/fs.js'); + vi.mocked(loadTeamConfig).mockResolvedValue(null); + + await init({ repo: GITLAB_REPO, provider: 'git', scope: 'user', role: 'hai' }); + + expect(mockExit).not.toHaveBeenCalled(); + const teamYaml = vi.mocked(writeFile).mock.calls.find(([p]) => String(p).endsWith('teamai.yaml')); + expect(teamYaml?.[1]).toContain('"provider":"git"'); + }); + + it('records no provider when the flag is omitted, so later runs follow the team repo', async () => { + await init({ repo: GITLAB_REPO, scope: 'user', role: 'hai' }); + + expect(saveLocalConfig).toHaveBeenCalledWith(expect.not.objectContaining({ provider: expect.anything() })); + }); + + it('applies to single-repo mode as well', async () => { + vi.stubEnv('GITLAB_URL', 'https://gitlab.example.test'); + pathExistsFn = (p: string) => p.endsWith(`${path.sep}.git`) || p.endsWith('/.git'); + mockGit.raw.mockResolvedValue(`${GITLAB_REPO}\n`); + const { loadTeamConfig, saveLocalConfigForScope } = await import('../config.js'); + // The team's committed teamai.yaml keeps saying gitlab; only this member opts out. + vi.mocked(loadTeamConfig).mockResolvedValue({ + team: 'team-repo', + description: '', + repo: GITLAB_REPO, + provider: 'gitlab', + reviewers: [], + sharing: { rules: { enforced: [] }, docs: {}, env: { injectShellProfile: true } }, + toolPaths: {}, + } as never); + + await init({ repo: '.', provider: 'git', role: 'hai', dryRun: true }); + + expect(mockExit).not.toHaveBeenCalled(); + expect(GitLabProvider.prototype.authenticate).not.toHaveBeenCalled(); + // A new committed .teamai/teamai.yaml keeps the host's provider for teammates. + const { writeFile } = await import('../utils/fs.js'); + const teamYaml = vi.mocked(writeFile).mock.calls.find(([p]) => String(p).endsWith('teamai.yaml')); + expect(teamYaml?.[1]).toContain('"provider":"gitlab"'); + expect(saveLocalConfigForScope).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'git', username: 'plain-member' }), + 'project', + process.cwd(), + ); + }); + it('refuses to create a single-repo teamai.yaml on an unconfigured self-hosted GitLab', async () => { + fetchMock.mockResolvedValue(gitlabSignInResponse()); + pathExistsFn = (p: string) => p.endsWith(`${path.sep}.git`) || p.endsWith('/.git'); + mockGit.raw.mockResolvedValue(`${GITLAB_REPO}\n`); + const { saveLocalConfigForScope } = await import('../config.js'); + const { writeFile } = await import('../utils/fs.js'); + const { log } = await import('../utils/logger.js'); + + await init({ repo: '.', provider: 'git', role: 'hai', dryRun: true }); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Set GITLAB_URL=https://gitlab.example.test')); + expect(vi.mocked(writeFile).mock.calls.find(([p]) => String(p).endsWith('teamai.yaml'))).toBeUndefined(); + expect(saveLocalConfigForScope).not.toHaveBeenCalled(); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts index 030bd1ce6..b6fdd483d 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -355,7 +355,8 @@ export type CheckStage = 'pull' | 'doctor'; */ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'doctor'): Promise { const { localConfig, teamConfig, toolPaths, hookToolPaths, baseDir } = ctx; - const providerName = teamConfig?.provider; + // A member's `init --provider` choice outranks the team's provider (#789). + const providerName = localConfig.provider ?? teamConfig?.provider; const checks: Check[] = []; // Provider-specific checks: gf CLI only needed for TGit, gh CLI for GitHub diff --git a/src/import-repo-list.ts b/src/import-repo-list.ts index c9dc4860b..d434b0289 100644 --- a/src/import-repo-list.ts +++ b/src/import-repo-list.ts @@ -198,7 +198,7 @@ export async function importFromRepoList( '[teamai] Batch import: graph', ['.'], { repo: tc.repo, provider: tc.provider, reviewers: tc.reviewers }, - { repo: lc.repo, username: lc.username }, + { repo: lc.repo, username: lc.username, provider: lc.provider }, ); if (prUrl) { log.success(`MR created: ${prUrl}`); diff --git a/src/import-repo.ts b/src/import-repo.ts index 4395d88c6..0cb06e109 100644 --- a/src/import-repo.ts +++ b/src/import-repo.ts @@ -284,14 +284,14 @@ export async function importFromRepo(opts: ImportFromRepoOptions): Promise let teamRepoDir: string; let teamRepoRemote = ''; let mrTeamConfig: { repo: string; provider?: string; reviewers?: string[] } | null = null; - let mrLocalConfig: { repo: { remote: string; localPath: string }; username: string } | null = null; + let mrLocalConfig: { repo: { remote: string; localPath: string }; username: string; provider?: string } | null = null; try { const { autoDetectInit } = await import('./config.js'); const { localConfig: lc, teamConfig: tc } = await autoDetectInit(); teamRepoDir = lc.repo.localPath; teamRepoRemote = lc.repo.remote; mrTeamConfig = { repo: tc.repo, provider: tc.provider, reviewers: tc.reviewers }; - mrLocalConfig = { repo: lc.repo, username: lc.username }; + mrLocalConfig = { repo: lc.repo, username: lc.username, provider: lc.provider }; } catch { teamRepoDir = path.join(process.cwd(), '.teamai', 'team-repo'); } diff --git a/src/import.ts b/src/import.ts index 5272948f8..8d6833e52 100644 --- a/src/import.ts +++ b/src/import.ts @@ -359,7 +359,7 @@ export async function importCmd(opts: ImportOptions): Promise { `[teamai] Import from MR: ${opts.fromMr}`, ['.'], { repo: teamConfig.repo, provider: teamConfig.provider, reviewers: teamConfig.reviewers }, - { repo: localConfig.repo, username: localConfig.username }, + { repo: localConfig.repo, username: localConfig.username, provider: localConfig.provider }, ); }, }, diff --git a/src/index.ts b/src/index.ts index 2e1ffa9de..b82e5e4a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,6 +120,7 @@ program .argument('[repo]', 'Team repo (owner/repo or full URL). Pass "." for single-repo mode (the current git repo is the team repo).') .option('--repo ', 'Team repo (alias of the positional argument)') .option('--http ', 'Git-free HTTP team repo (read-only consumer; only needs an API key)') + .option('--provider ', 'Git provider for the team repo on this machine: tgit, github, cnb, gitlab, gitcode, or git. Skips auto-detection. `git` uses your existing Git auth and needs no platform token, but opens no PR/MR.') .option('--self', 'Single-repo mode: the current git repo is the team repo (equivalent to `teamai init .`). Knowledge lives on main under .teamai/; reports go to the teamai-reports orphan branch.') .option('--token ', 'API key for HTTP team repo / status reporting (stored 0600, never committed). Also reads TEAMAI_API_TOKEN.') .option('--scope ', 'Install scope: project (default, /.teamai + /.claude) or user (~/.teamai + ~/.claude)') diff --git a/src/init.ts b/src/init.ts index b6c3446f1..ef6d6267a 100644 --- a/src/init.ts +++ b/src/init.ts @@ -5,8 +5,9 @@ import { saveLocalConfig, loadTeamConfig, saveLocalConfigForScope, loadLocalConf import { describeUnappliedTeamHooks, hasTeamaiHooks, reconcileHooks, reconcileTeamHooksForConfig } from './hooks.js'; import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials, pullRepoFastForward } from './utils/git.js'; import { pushRepoDirectly } from './utils/git.js'; -import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; +import { getProvider, detectProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js'; import { parseGenericGitExistingRemote } from './providers/git/repo-url.js'; +import { probeSelfHostedGitLab } from './providers/gitlab/probe.js'; import { ensureDir, writeFile, writeFileAtomic, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js'; import { queueOwner, sameQueueOwner, setAsideQueueOnModeSwitch } from './utils/pending-learnings.js'; import { dropAllSearchIndexes } from './utils/search-index.js'; @@ -47,6 +48,8 @@ import { REPORTS_BRANCH, type GlobalOptions, type LocalConfig, + ProviderNameSchema, + type ProviderName, type Scope, getTeamaiHome, getConfigPath, @@ -418,6 +421,61 @@ export function resolveInitRepo( return pos ?? flag; } +/** + * Validate `init --provider`: an explicit provider that replaces auto-detection + * (#789), so a member of a GitLab team can use plain git without a token. + */ +export function resolveInitProvider(raw: string | undefined): ProviderName | undefined { + if (raw === undefined) return undefined; + const parsed = ProviderNameSchema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Invalid --provider "${raw}". Use one of: ${ProviderNameSchema.options.join(', ')}, ` + + 'or omit --provider to detect it from the repo URL.', + ); + } + return parsed.data; +} + +/** + * The provider init uses for `input`: the `--provider` choice when given, else + * auto-detection. + */ +async function selectInitProvider(input: string, forced: ProviderName | undefined): Promise { + if (!forced) return detectProviderForInit(input); + // The GitLab API client targets GITLAB_URL or TEAMAI_GITLAB_HOST (default + // gitlab.com), not the repo URL's host, so on an unconfigured host it would + // send the token elsewhere. + if (forced === 'gitlab' && detectProvider(input) === 'git') { + throw new Error( + '--provider gitlab needs this GitLab instance configured. Set GITLAB_URL to its base URL ' + + '(for example https://gitlab.example.com) and GITLAB_TOKEN, then run teamai init again. ' + + 'To use your existing Git authentication without a token, pass --provider git.', + ); + } + log.info(`Provider: ${forced} (--provider; auto-detection skipped)`); + return forced; +} + +/** + * The provider a new teamai.yaml records for the whole team. `--provider git` + * is one member's opt-out, so the host's provider is resolved as init would + * without the flag. An unconfigured self-hosted GitLab stops init: recording + * `git` there would cost every teammate automatic merge requests. + */ +async function newTeamConfigProvider(input: string, providerName: string, forced: ProviderName | undefined): Promise { + if (forced !== 'git') return providerName; + const detected = detectProvider(input); + if (detected !== 'git') return detected; + const gitlab = await probeSelfHostedGitLab(input); + if (!gitlab) return 'git'; + throw new Error( + `Creating teamai.yaml records the team's provider, and ${gitlab.baseUrl} is a self-hosted GitLab ` + + `that is not configured. Set GITLAB_URL=${gitlab.baseUrl} and run teamai init again. ` + + '--provider git still keeps this machine on your Git authentication, without a GitLab token.', + ); +} + function printScopeSummary( scope: Scope, projectRoot: string | undefined, @@ -880,6 +938,7 @@ export async function promptForSelfModeAgents(options: { export async function initSelfRepo(options: GlobalOptions & { repo?: string; repoPositional?: string; + provider?: ProviderName; role?: string; project?: string; agent?: string | string[]; @@ -946,14 +1005,14 @@ export async function initSelfRepo(options: GlobalOptions & { } let providerName: string; try { - providerName = await detectProviderForInit(remoteUrl); + providerName = await selectInitProvider(remoteUrl, options.provider); } catch (e) { log.error((e as Error).message); process.exit(1); return; } const provider = getProvider(providerName); - log.debug(`Detected provider: ${providerName} (from ${redactGitCredentials(remoteUrl)})`); + if (!options.provider) log.debug(`Detected provider: ${providerName} (from ${redactGitCredentials(remoteUrl)})`); let repoInfo; try { @@ -995,12 +1054,20 @@ export async function initSelfRepo(options: GlobalOptions & { // teamai.yaml carries `mode: self` so teammates auto-bootstrap after clone. const teamaiYamlPath = path.join(localPath, 'teamai.yaml'); if (!await pathExists(teamaiYamlPath)) { + let teamProvider: string; + try { + teamProvider = await newTeamConfigProvider(remoteUrl, providerName, options.provider); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + return; + } const defaultConfig = YAML.stringify({ team: repoInfo.repo, mode: 'self', description: 'TeamAI single-repo (knowledge on main, reports on teamai-reports)', repo: repoInfo.httpsUrl, - provider: providerName, + provider: teamProvider, sharing: { rules: { enforced: [] }, docs: { localDir: './.teamai/docs' }, @@ -1023,6 +1090,7 @@ export async function initSelfRepo(options: GlobalOptions & { const localConfig: LocalConfig = { repo: { localPath, remote: repoInfo.httpsUrl, kind: 'self', businessRepoRoot }, username, + ...(options.provider ? { provider: options.provider } : {}), scope: 'project', projectRoot: businessRepoRoot, dataHome: partitionHome, @@ -1240,7 +1308,19 @@ export async function init(options: GlobalOptions & { token?: string; inheritUserScope?: boolean; self?: boolean; + provider?: string; }): Promise { + let forcedProvider: ProviderName | undefined; + try { + forcedProvider = resolveInitProvider(options.provider); + if (forcedProvider && options.http) { + throw new Error('--provider cannot be combined with --http: an HTTP team repo has no git provider.'); + } + } catch (e) { + log.error((e as Error).message); + process.exit(1); + return; + } if (options.http) { return initHttp(options.http, options); } @@ -1249,7 +1329,7 @@ export async function init(options: GlobalOptions & { // the teamai-reports orphan branch. No separate team repo is cloned. const repoArg = (options.repoPositional ?? options.repo ?? '').trim(); if (options.self || repoArg === '.') { - return initSelfRepo(options); + return initSelfRepo({ ...options, provider: forcedProvider }); } log.info('Initializing teamai...'); @@ -1332,14 +1412,14 @@ export async function init(options: GlobalOptions & { // Step 1b: Detect and initialize provider from URL let providerName: string; try { - providerName = await detectProviderForInit(repoInput); + providerName = await selectInitProvider(repoInput, forcedProvider); } catch (e) { log.error((e as Error).message); process.exit(1); return; } const provider = getProvider(providerName); - log.debug(`Detected provider: ${providerName}`); + if (!forcedProvider) log.debug(`Detected provider: ${providerName}`); let repoInfo; try { @@ -1547,11 +1627,19 @@ export async function init(options: GlobalOptions & { const createdSkeleton = !teamConfig; if (!teamConfig) { log.warn('teamai.yaml not found in repo. Creating default config...'); + let teamProvider: string; + try { + teamProvider = await newTeamConfigProvider(repoInput, providerName, forcedProvider); + } catch (e) { + log.error((e as Error).message); + process.exit(1); + return; + } const defaultConfig = YAML.stringify({ team: 'my-team', description: 'TeamAI shared resources', repo: repoInfo.httpsUrl, - provider: providerName, + provider: teamProvider, sharing: { rules: { enforced: [] }, docs: { localDir: scope === 'project' ? './.teamai/docs' : '~/.teamai/docs' }, @@ -1703,6 +1791,7 @@ export async function init(options: GlobalOptions & { const localConfig: LocalConfig = { repo: { localPath, remote: repoInfo.httpsUrl }, username, + ...(forcedProvider ? { provider: forcedProvider } : {}), scope, projectRoot, additionalRoles: [], diff --git a/src/providers/gitlab/gitlab-api.ts b/src/providers/gitlab/gitlab-api.ts index 3e2a91c7d..9409bf0d5 100644 --- a/src/providers/gitlab/gitlab-api.ts +++ b/src/providers/gitlab/gitlab-api.ts @@ -60,11 +60,27 @@ function resolveGitLabBaseUrl(): string { 'scheme, e.g. https://gitlab.example.com', ); } + assertSameGitLabHost(new URL(gitlabUrl)); return gitlabUrl.replace(/\/+$/, ''); } return `https://${GITLAB_HOST}`; } +/** + * Repo URLs on TEAMAI_GITLAB_HOST are routed to the GitLab provider, and its + * API calls go to GITLAB_URL. When the two name different hosts, the token for + * one instance would be sent to the other, so refuse instead of choosing. + */ +function assertSameGitLabHost(gitlabUrl: URL): void { + const override = process.env.TEAMAI_GITLAB_HOST?.trim().toLowerCase(); + if (!override || override === gitlabUrl.host || override === gitlabUrl.hostname) return; + throw new Error( + `TEAMAI_GITLAB_HOST (${override}) and GITLAB_URL (${gitlabUrl.origin}) name different GitLab ` + + 'hosts, so teamai cannot tell which one your token belongs to. Unset TEAMAI_GITLAB_HOST, ' + + 'or set GITLAB_URL to the base URL of that host, then run the command again.', + ); +} + /** Resolve the GitLab token from env, honouring the documented aliases. */ export function getGitLabToken(): string | null { // Trim and treat blank as absent: CI often declares GITLAB_TOKEN with an unset @@ -112,8 +128,11 @@ export function gitlabIsAuthenticated(): boolean { export async function gitlabWhoami(): Promise { const token = getGitLabToken(); if (!token) return null; + // Resolved outside the try: a GITLAB_URL configuration error must reach the + // user instead of reading as a failed login. + const url = `${gitlabApiBase()}/user`; try { - const resp = await fetch(`${gitlabApiBase()}/user`, { + const resp = await fetch(url, { headers: authHeaders(token), redirect: 'manual', }); diff --git a/src/push.ts b/src/push.ts index 091e7828b..0ad93fd72 100644 --- a/src/push.ts +++ b/src/push.ts @@ -233,12 +233,13 @@ async function resolveNamespaceForNew( */ async function createPrWithFallback( teamConfig: { repo: string; provider?: string; reviewers?: string[] }, - localConfig: { repo: { remote: string; localPath: string } }, + localConfig: { repo: { remote: string; localPath: string }; provider?: string }, branchName: string, title: string, description: string, ): Promise { - const provider = getProvider(teamConfig.provider); + // A member's `init --provider` choice outranks the team's provider (#789). + const provider = getProvider(localConfig.provider ?? teamConfig.provider); const mrSpin = spinner('Creating Pull Request...').start(); let repoInput = teamConfig.repo; try { @@ -265,7 +266,9 @@ async function createPrWithFallback( } catch (e) { mrSpin.fail(`Failed to create PR: ${(e as Error).message}`); log.info(`Branch ${branchName} has been pushed. You can create a PR manually.`); - if (provider.name === 'git') { + if (localConfig.provider === 'git') { + log.info('This machine uses provider git (teamai init --provider git), which does not create pull/merge requests.'); + } else if (provider.name === 'git') { const { detectProvider } = await import('./providers/registry.js'); const { probeSelfHostedGitLab } = await import('./providers/gitlab/probe.js'); const repoUrl = repoInput || localConfig.repo.remote; diff --git a/src/types.ts b/src/types.ts index 10d874e37..b0f4f026d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -264,12 +264,15 @@ export const SOURCE_PULL_TTL_MS = 24 * 60 * 60 * 1000; export const TEAMAI_SOURCES_DIR = path.join(getUserHome(), '.teamai', 'sources'); +/** Git hosting provider. `git` is the transport-only fallback for arbitrary hosts. */ +export const ProviderNameSchema = z.enum(['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'git']); +export type ProviderName = z.infer; + export const TeamaiConfigSchema = z.object({ team: z.string(), description: z.string().default(''), repo: z.string(), - /** Git hosting provider. `git` is the transport-only fallback for arbitrary hosts. */ - provider: z.enum(['tgit', 'github', 'cnb', 'gitlab', 'gitcode', 'git']).default('tgit'), + provider: ProviderNameSchema.default('tgit'), /** * @deprecated Ignored by `teamai init` (issue #250). Local install scope is * decided only by CLI `--scope` / default. Kept optional for old teamai.yaml files. @@ -536,6 +539,12 @@ export const LocalConfigSchema = z.object({ businessRepoRoot: z.string().optional(), }), username: z.string(), + /** + * The provider this member uses for the team repo, set by `init --provider` + * (#789). It overrides teamai.yaml `provider` on this machine only, e.g. `git` + * so a member of a GitLab team needs no GITLAB_TOKEN. Absent = the team's. + */ + provider: ProviderNameSchema.optional(), updatePolicy: z.enum(['auto', 'prompt', 'skip']).optional(), // Read-compat default for historical configs that omit `scope` (pre-project era). // NOT the write default for `teamai init` — init defaults to project (issue #250). diff --git a/src/utils/git.ts b/src/utils/git.ts index d3e685b66..9166241cd 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -419,7 +419,7 @@ export async function autoPushViaMR( message: string, files: string[], teamConfig: { repo: string; provider?: string; reviewers?: string[] }, - localConfig: { repo: { remote: string; localPath: string }; username: string }, + localConfig: { repo: { remote: string; localPath: string }; username: string; provider?: string }, ): Promise { try { const branchName = generateBranchName(localConfig.username);