mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
fix(init): allow existing HTTP origin safely (#552)
* fix(init): redact invalid business remote * fix(init): allow existing HTTP origin
This commit is contained in:
@@ -50,6 +50,8 @@ teamai init git@code.qschou.com:Enterprise/arb-workflow-kit.git --scope user
|
||||
- HTTPS:预先配置 Git Credential Helper;不要把用户名、密码或 Token 写进 URL。
|
||||
- SSH:预先配置 SSH Key,并确保 `ssh-agent` 能访问私钥。
|
||||
|
||||
`teamai init .` 是一个受限例外:它只读取当前业务仓已经配置的 `origin`。若该 origin 是遗留的 HTTP Basic URL(例如 `http://user:token@host/group/repo.git`),初始化会使用其 host/path 识别仓库,但会在写入 `.teamai/teamai.yaml`、本地 TeamAI 配置和日志前移除用户名与 Token。普通 `teamai init <url>`、clone 和其他通用 Git URL 输入仍拒绝 HTTP 与 URL 内嵌凭据。HTTP 本身不会加密 Git 传输;应尽快迁移到 HTTPS + Credential Helper 或 SSH。
|
||||
|
||||
clone、pull、push 均可正常使用。平台 API 操作(自动建仓、自动创建 MR/PR)无法跨不同服务统一实现,因此暂不支持;`teamai push` 会先推送分支,再提示用户到对应平台手动创建 MR,并以非零退出码表明自动 PR/MR 创建未完成。
|
||||
|
||||
若已有 `provider: git` 的仓库在创建 PR 时失败,CLI 会额外检查该 host;确认是 GitLab 后,会提示设置实例地址和 token,并将团队仓库 `teamai.yaml` 的 `provider` 改为 `gitlab`。这个提示不会自动修改配置,也不会撤回已经推送的分支。
|
||||
|
||||
@@ -6,7 +6,7 @@ vi.mock('node:child_process', () => ({
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { GenericGitProvider, normalizeGitIdentity } from '../providers/git/index.js';
|
||||
import { parseGenericGitRepoInput } from '../providers/git/repo-url.js';
|
||||
import { parseGenericGitExistingRemote, parseGenericGitRepoInput } from '../providers/git/repo-url.js';
|
||||
import { detectProvider, getProvider } from '../providers/registry.js';
|
||||
|
||||
const mockedSpawnSync = spawnSync as Mock;
|
||||
@@ -95,6 +95,17 @@ describe('parseGenericGitRepoInput', () => {
|
||||
expect(message).toMatch(/query strings and fragments are not supported/);
|
||||
expect(message).not.toContain('secret-value');
|
||||
});
|
||||
|
||||
it('parses an existing insecure origin without preserving its credentials', () => {
|
||||
expect(parseGenericGitExistingRemote(
|
||||
'http://user:token-must-not-persist@git.example.com/group/repo.git',
|
||||
)).toEqual({
|
||||
owner: 'group',
|
||||
repo: 'repo',
|
||||
httpsUrl: 'http://git.example.com/group/repo.git',
|
||||
projectId: encodeURIComponent('group/repo'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic Git identity', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockGit = {
|
||||
commit: vi.fn(),
|
||||
push: vi.fn(),
|
||||
revparse: vi.fn().mockResolvedValue('main'),
|
||||
raw: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
@@ -718,4 +719,41 @@ describe('init', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-repo mode', () => {
|
||||
it('accepts an existing HTTP origin without persisting its credentials', async () => {
|
||||
pathExistsFn = (p: string) => p.endsWith(`${path.sep}.git`) || p.endsWith('/.git');
|
||||
mockGit.raw.mockResolvedValue(
|
||||
'http://user:token-must-not-appear@git.example.com/group/repo.git\n',
|
||||
);
|
||||
|
||||
const { log } = await import('../utils/logger.js');
|
||||
const { loadTeamConfig, saveLocalConfigForScope } = await import('../config.js');
|
||||
vi.mocked(loadTeamConfig).mockResolvedValue({
|
||||
team: 'repo',
|
||||
description: '',
|
||||
repo: 'http://git.example.com/group/repo.git',
|
||||
provider: 'git',
|
||||
reviewers: [],
|
||||
sharing: { rules: { enforced: [] }, docs: {}, env: { injectShellProfile: true } },
|
||||
toolPaths: {},
|
||||
} as never);
|
||||
|
||||
await init({ repo: '.', dryRun: true });
|
||||
|
||||
const errorCalls = vi.mocked(log.error).mock.calls.map(([message]) => String(message));
|
||||
const debugCalls = vi.mocked(log.debug).mock.calls.map(([message]) => String(message));
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
expect(errorCalls).not.toContainEqual(expect.stringContaining('Could not parse the business repo remote'));
|
||||
expect(errorCalls.join('\n')).not.toContain('token-must-not-appear');
|
||||
expect(debugCalls.join('\n')).not.toContain('token-must-not-appear');
|
||||
expect(saveLocalConfigForScope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repo: expect.objectContaining({ remote: 'http://git.example.com/group/repo.git' }),
|
||||
}),
|
||||
'project',
|
||||
process.cwd(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-3
@@ -6,6 +6,7 @@ import { reconcileTeamHooksForConfig } from './hooks.js';
|
||||
import { configureGitUser, initRepo, isGitRepo, getRemoteUrl, remotesMatch, redactGitCredentials } from './utils/git.js';
|
||||
import { pushRepoDirectly } from './utils/git.js';
|
||||
import { getProvider, detectProviderForInit, RepoNotFoundError, OrganizationNotFoundError, RepoCreatePermissionError } from './providers/index.js';
|
||||
import { parseGenericGitExistingRemote } from './providers/git/repo-url.js';
|
||||
import { ensureDir, writeFile, pathExists, expandHome, readFileSafe, remove } from './utils/fs.js';
|
||||
import { log, spinner } from './utils/logger.js';
|
||||
import {
|
||||
@@ -744,13 +745,15 @@ export async function initSelfRepo(options: GlobalOptions & {
|
||||
return;
|
||||
}
|
||||
const provider = getProvider(providerName);
|
||||
log.debug(`Detected provider: ${providerName} (from ${remoteUrl})`);
|
||||
log.debug(`Detected provider: ${providerName} (from ${redactGitCredentials(remoteUrl)})`);
|
||||
|
||||
let repoInfo;
|
||||
try {
|
||||
repoInfo = provider.parseRepoInput(remoteUrl);
|
||||
repoInfo = providerName === 'git'
|
||||
? parseGenericGitExistingRemote(remoteUrl)
|
||||
: provider.parseRepoInput(remoteUrl);
|
||||
} catch (e) {
|
||||
log.error(`Could not parse the business repo remote "${remoteUrl}": ${(e as Error).message}`);
|
||||
log.error(`Could not parse the business repo remote "${redactGitCredentials(remoteUrl)}": ${(e as Error).message}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,15 +39,19 @@ function buildRepoInfo(owner: string, repo: string, remoteUrl: string): RepoInfo
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a full HTTPS or SSH clone URL for an arbitrary Git host. */
|
||||
export function parseGenericGitRepoInput(input: string): RepoInfo {
|
||||
const trimmed = input.trim();
|
||||
interface ParseOptions {
|
||||
allowExistingInsecureOrigin?: boolean;
|
||||
}
|
||||
|
||||
if (/^http:\/\//i.test(trimmed)) {
|
||||
function parseGenericGitRepoInputWithOptions(input: string, options: ParseOptions = {}): RepoInfo {
|
||||
const trimmed = input.trim();
|
||||
const allowExistingInsecureOrigin = options.allowExistingInsecureOrigin === true;
|
||||
|
||||
if (/^http:\/\//i.test(trimmed) && !allowExistingInsecureOrigin) {
|
||||
throw invalidRepoUrl('plain HTTP is not supported; use HTTPS or SSH');
|
||||
}
|
||||
|
||||
if (/^https:\/\//i.test(trimmed) || /^ssh:\/\//i.test(trimmed)) {
|
||||
if (/^https?:\/\//i.test(trimmed) || /^ssh:\/\//i.test(trimmed)) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
@@ -56,7 +60,7 @@ export function parseGenericGitRepoInput(input: string): RepoInfo {
|
||||
}
|
||||
|
||||
const httpCredentials = /^https?:$/i.test(parsed.protocol) && (parsed.username || parsed.password);
|
||||
if (!parsed.hostname || parsed.password || httpCredentials) {
|
||||
if (!parsed.hostname || (!allowExistingInsecureOrigin && (parsed.password || httpCredentials))) {
|
||||
throw new Error(
|
||||
'Invalid Git repo URL. Do not embed credentials in the URL; '
|
||||
+ 'configure a Git credential helper or SSH key instead.',
|
||||
@@ -67,7 +71,9 @@ export function parseGenericGitRepoInput(input: string): RepoInfo {
|
||||
}
|
||||
|
||||
const { owner, repo, fullPath } = parsePath(parsed.pathname);
|
||||
const auth = parsed.username ? `${parsed.username}@` : '';
|
||||
// A pre-existing local origin may contain HTTP Basic credentials. They are
|
||||
// needed only by the user's Git config, never by TeamAI configuration.
|
||||
const auth = /^ssh:$/i.test(parsed.protocol) && parsed.username ? `${parsed.username}@` : '';
|
||||
const remoteUrl = `${parsed.protocol}//${auth}${parsed.host}/${fullPath}.git`;
|
||||
return buildRepoInfo(owner, repo, remoteUrl);
|
||||
}
|
||||
@@ -89,3 +95,19 @@ export function parseGenericGitRepoInput(input: string): RepoInfo {
|
||||
|
||||
throw invalidRepoUrl();
|
||||
}
|
||||
|
||||
/** Parse a full HTTPS or SSH clone URL for an arbitrary Git host. */
|
||||
export function parseGenericGitRepoInput(input: string): RepoInfo {
|
||||
return parseGenericGitRepoInputWithOptions(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the already-configured `origin` of a single-repo installation.
|
||||
*
|
||||
* This accepts legacy HTTP and URL userinfo only to identify the existing
|
||||
* repository. The returned canonical URL deliberately strips userinfo so
|
||||
* TeamAI never persists credentials into config or teamai.yaml.
|
||||
*/
|
||||
export function parseGenericGitExistingRemote(input: string): RepoInfo {
|
||||
return parseGenericGitRepoInputWithOptions(input, { allowExistingInsecureOrigin: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user