@cdo-ai/cli 0.1.0
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/bin/cdo.mjs +277 -0
- package/lib/api.mjs +64 -0
- package/lib/browser.mjs +14 -0
- package/lib/config.mjs +392 -0
- package/lib/doctor.mjs +71 -0
- package/lib/git-auth.mjs +343 -0
- package/lib/login.mjs +167 -0
- package/lib/skills.mjs +270 -0
- package/lib/version.mjs +4 -0
- package/lib/zip.mjs +134 -0
- package/package.json +32 -0
- package/scripts/postinstall.mjs +30 -0
package/lib/git-auth.mjs
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
4
|
+
import { chmod, lstat, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { parse, stringify } from "yaml";
|
|
9
|
+
|
|
10
|
+
import { VERSION } from "./version.mjs";
|
|
11
|
+
|
|
12
|
+
const execute = promisify(execFile);
|
|
13
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 20);
|
|
14
|
+
const FILE_LOCK_TIMEOUT_MS = 5_000;
|
|
15
|
+
const FILE_LOCK_STALE_MS = 30_000;
|
|
16
|
+
const GIT_CONTROLS = Object.freeze({
|
|
17
|
+
dev: "https://app-hosting.dev-agents.cdo.top",
|
|
18
|
+
staging: "https://app-hosting.s-agents.cdo.top",
|
|
19
|
+
prod: "https://app-hosting.ttdd.work",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export function validateGitControl(value, environmentName, source = "CDO_GIT_API_URL") {
|
|
23
|
+
if (!Object.hasOwn(GIT_CONTROLS, environmentName)) throw new Error("Git 控制面环境无效");
|
|
24
|
+
let endpoint;
|
|
25
|
+
try { endpoint = new URL(value); } catch { throw new Error(`${source} 无效`); }
|
|
26
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password
|
|
27
|
+
|| endpoint.pathname !== "/" || endpoint.search || endpoint.hash) {
|
|
28
|
+
throw new Error(`${source} 必须是无凭据的 HTTP(S) origin`);
|
|
29
|
+
}
|
|
30
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(endpoint.hostname.toLowerCase());
|
|
31
|
+
const normalized = endpoint.toString().replace(/\/$/, "");
|
|
32
|
+
if (loopback) {
|
|
33
|
+
if (environmentName === "dev" && endpoint.protocol === "http:" && endpoint.port) return normalized;
|
|
34
|
+
throw new Error(`${source} 与所选 ${environmentName} 环境不匹配`);
|
|
35
|
+
}
|
|
36
|
+
if (endpoint.protocol !== "https:") throw new Error(`${source} 必须使用 HTTPS`);
|
|
37
|
+
if (normalized !== GIT_CONTROLS[environmentName]) {
|
|
38
|
+
throw new Error(`${source} 与所选 ${environmentName} 环境不匹配`);
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Validate every endpoint-bearing readiness fact before OAuth or local credential writes. */
|
|
44
|
+
export function validateGitReadiness(facts, environmentName) {
|
|
45
|
+
let giteaUrl;
|
|
46
|
+
let sshUrl;
|
|
47
|
+
try {
|
|
48
|
+
giteaUrl = new URL(facts?.gitea_url);
|
|
49
|
+
sshUrl = new URL(facts?.ssh_url);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error("Git 状态响应无效");
|
|
52
|
+
}
|
|
53
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes(giteaUrl.hostname.toLowerCase());
|
|
54
|
+
const safeGitea = ["http:", "https:"].includes(giteaUrl.protocol)
|
|
55
|
+
&& !giteaUrl.username && !giteaUrl.password && giteaUrl.pathname === "/"
|
|
56
|
+
&& !giteaUrl.search && !giteaUrl.hash
|
|
57
|
+
&& (giteaUrl.protocol === "https:" || (environmentName === "dev" && loopback && Boolean(giteaUrl.port)));
|
|
58
|
+
const safeSsh = sshUrl.protocol === "ssh:" && sshUrl.username === "git" && !sshUrl.password
|
|
59
|
+
&& /^[a-zA-Z0-9.-]+$/.test(sshUrl.hostname) && !sshUrl.search && !sshUrl.hash
|
|
60
|
+
&& ["", "/"].includes(sshUrl.pathname);
|
|
61
|
+
if (!facts?.enterprise_id || !facts?.username || !safeGitea || !safeSsh) {
|
|
62
|
+
throw new Error("Git 状态响应无效");
|
|
63
|
+
}
|
|
64
|
+
return facts;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function regularFile(path) {
|
|
68
|
+
try {
|
|
69
|
+
const info = await lstat(path);
|
|
70
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error("认证配置不是普通文件,拒绝覆盖");
|
|
71
|
+
return true;
|
|
72
|
+
} catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function atomicPrivate(path, contents) {
|
|
76
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
77
|
+
await regularFile(path);
|
|
78
|
+
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
79
|
+
await writeFile(temporary, contents, { mode: 0o600, flag: "wx" });
|
|
80
|
+
await rename(temporary, path);
|
|
81
|
+
await chmod(path, 0o600);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Serialize one short local read/merge/write without holding the lock across OAuth. */
|
|
85
|
+
async function withFileLock(path, operation) {
|
|
86
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
87
|
+
const lockPath = `${path}.cdo.lock`;
|
|
88
|
+
const deadline = Date.now() + FILE_LOCK_TIMEOUT_MS;
|
|
89
|
+
let handle;
|
|
90
|
+
while (!handle) {
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
93
|
+
await handle.writeFile(`${process.pid}\n`, "utf8");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.code !== "EEXIST") throw error;
|
|
96
|
+
const age = await stat(lockPath).then((info) => Date.now() - info.mtimeMs).catch(() => 0);
|
|
97
|
+
if (age > FILE_LOCK_STALE_MS) { await unlink(lockPath).catch(() => {}); continue; }
|
|
98
|
+
if (Date.now() >= deadline) throw new Error(`认证配置正被其他进程更新:${path}`);
|
|
99
|
+
await new Promise((done) => setTimeout(done, 20));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try { return await operation(); }
|
|
103
|
+
finally { await handle.close().catch(() => {}); await unlink(lockPath).catch(() => {}); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function safeProfileIdentity(value) {
|
|
107
|
+
return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "-") || "direct";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function gitLocations(profileId, home = homedir()) {
|
|
111
|
+
const identity = safeProfileIdentity(profileId);
|
|
112
|
+
const key = join(home, ".config", "cdo", "git", identity, "id_ed25519");
|
|
113
|
+
return { key, sshConfig: join(home, ".ssh", "config") };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function sshBlock(existing, profileId, sshUrl, key) {
|
|
117
|
+
const parsed = new URL(sshUrl);
|
|
118
|
+
const identity = safeProfileIdentity(profileId);
|
|
119
|
+
if (!profileId || parsed.protocol !== "ssh:" || parsed.username !== "git"
|
|
120
|
+
|| parsed.password || !/^[a-zA-Z0-9.-]+$/.test(parsed.hostname) || /[\n\r"]/.test(key)) {
|
|
121
|
+
throw new Error("服务端 Git SSH 配置无效");
|
|
122
|
+
}
|
|
123
|
+
const alias = `cdo-${identity}`;
|
|
124
|
+
const begin = `# BEGIN cdo-git-${identity}`;
|
|
125
|
+
const end = `# END cdo-git-${identity}`;
|
|
126
|
+
const start = existing.indexOf(begin);
|
|
127
|
+
if (start >= 0) {
|
|
128
|
+
const finish = existing.indexOf(end, start);
|
|
129
|
+
if (finish < 0 || existing.indexOf(begin, start + begin.length) >= 0) throw new Error("Git SSH managed block 不完整");
|
|
130
|
+
existing = existing.slice(0, start) + existing.slice(finish + end.length).replace(/^\r?\n/, "");
|
|
131
|
+
} else if (existing.includes(end)) throw new Error("Git SSH managed block 不完整");
|
|
132
|
+
const reset = "# cdo-git: resume user configuration\nHost *\n";
|
|
133
|
+
existing = existing.replace(reset, "");
|
|
134
|
+
let managedPrefix = "";
|
|
135
|
+
while (existing.startsWith("# BEGIN cdo-git-")) {
|
|
136
|
+
const firstLineEnd = existing.indexOf("\n");
|
|
137
|
+
if (firstLineEnd < 0) throw new Error("Git SSH managed block 不完整");
|
|
138
|
+
const prefixBegin = existing.slice(0, firstLineEnd);
|
|
139
|
+
const prefixEnd = prefixBegin.replace("# BEGIN ", "# END ");
|
|
140
|
+
const prefixFinish = existing.indexOf(prefixEnd, firstLineEnd + 1);
|
|
141
|
+
if (prefixFinish < 0) throw new Error("Git SSH managed block 不完整");
|
|
142
|
+
const nextLine = existing.indexOf("\n", prefixFinish + prefixEnd.length);
|
|
143
|
+
const consumed = nextLine < 0 ? existing.length : nextLine + 1;
|
|
144
|
+
managedPrefix += existing.slice(0, consumed);
|
|
145
|
+
existing = existing.slice(consumed);
|
|
146
|
+
}
|
|
147
|
+
const block = `${begin}\nHost ${alias}\n HostName ${parsed.hostname}\n User git\n Port ${parsed.port || "22"}\n IdentityFile "${key.replaceAll("\\", "/")}"\n UserKnownHostsFile "${join(dirname(key), "known_hosts").replaceAll("\\", "/")}"\n IdentitiesOnly yes\n ControlMaster no\n ControlPath none\n StrictHostKeyChecking accept-new\n${end}\n`;
|
|
148
|
+
// Reset to Host * before the user's original text so leading global options stay global.
|
|
149
|
+
return `${block}${managedPrefix}${reset}${existing}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function gitProfileId(context, facts) {
|
|
153
|
+
return context.profileId || context.profile?.id || `direct-${hash(`${context.environmentName}:${facts.enterprise_id}:${facts.username}`)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function teaConfigPath(environment = process.env, home = homedir(), platform = process.platform) {
|
|
157
|
+
const configHome = environment.XDG_CONFIG_HOME || (platform === "darwin"
|
|
158
|
+
? join(home, "Library", "Application Support")
|
|
159
|
+
: platform === "win32" ? environment.LOCALAPPDATA || join(home, "AppData", "Local") : join(home, ".config"));
|
|
160
|
+
const modern = join(configHome, "tea", "config.yml");
|
|
161
|
+
const legacy = join(home, ".tea", "tea.yml");
|
|
162
|
+
return (await regularFile(modern)) || !(await regularFile(legacy)) ? modern : legacy;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function saveTea(path, login, tokens) {
|
|
166
|
+
await withFileLock(path, async () => {
|
|
167
|
+
const config = await regularFile(path) ? parse(await readFile(path, "utf8")) : {};
|
|
168
|
+
if (!config || typeof config !== "object" || (config.logins && !Array.isArray(config.logins))) throw new Error("Tea 配置格式无效,未覆盖");
|
|
169
|
+
const logins = config.logins || [];
|
|
170
|
+
const previous = logins.find((item) => item.name === login.name);
|
|
171
|
+
if (previous && (previous.url !== login.url || previous.user !== login.user)) throw new Error("Tea 同名配置属于其他身份,未覆盖");
|
|
172
|
+
config.logins = [...logins.filter((item) => item.name !== login.name), {
|
|
173
|
+
...login, token: tokens.access_token, refresh_token: tokens.refresh_token,
|
|
174
|
+
token_expiry: Math.floor(Date.now() / 1000) + tokens.expires_in,
|
|
175
|
+
default: previous?.default ?? logins.length === 0, version_check: true,
|
|
176
|
+
}];
|
|
177
|
+
await atomicPrivate(path, stringify(config));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function responseJson(response, label) {
|
|
182
|
+
const body = await response.json().catch(() => null);
|
|
183
|
+
if (!response.ok) throw new Error(body?.detail || body?.error?.message || `${label} (${response.status})`);
|
|
184
|
+
return body?.data || body;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function authenticatedSSH(result, username) {
|
|
188
|
+
return `${result?.stdout || ""}${result?.stderr || ""}`.includes(`Hi there, ${username}! You've successfully authenticated`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function authenticatedTea(result, username) {
|
|
192
|
+
if (result?.error || (result?.status !== undefined && result.status !== 0)) return false;
|
|
193
|
+
try {
|
|
194
|
+
const identity = JSON.parse(result?.stdout || "");
|
|
195
|
+
return identity && identity.login === username && identity.is_admin !== true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function teaLoginMatches({ environment, home, name, url, user }) {
|
|
202
|
+
try {
|
|
203
|
+
const path = await teaConfigPath(environment, home);
|
|
204
|
+
if (!(await regularFile(path))) return false;
|
|
205
|
+
const config = parse(await readFile(path, "utf8"));
|
|
206
|
+
if (!config || typeof config !== "object" || !Array.isArray(config.logins)) return false;
|
|
207
|
+
const login = config.logins.find((item) => item?.name === name);
|
|
208
|
+
return login?.url === url && login?.user === user;
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function inspectGitAuth({ context, environment = process.env, home = homedir(), fetchImplementation = fetch, run = spawnSync }) {
|
|
215
|
+
let control;
|
|
216
|
+
try {
|
|
217
|
+
control = validateGitControl(
|
|
218
|
+
environment.CDO_GIT_API_URL || GIT_CONTROLS[context.environmentName],
|
|
219
|
+
context.environmentName,
|
|
220
|
+
environment.CDO_GIT_API_URL ? "CDO_GIT_API_URL" : "Git 控制面地址",
|
|
221
|
+
);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { status: "control_invalid", tea_api_ready: false, git_ssh_ready: false, error: error.message };
|
|
224
|
+
}
|
|
225
|
+
let facts;
|
|
226
|
+
try {
|
|
227
|
+
facts = await responseJson(await fetchImplementation(new URL("/api/v1/git/readiness", control), {
|
|
228
|
+
headers: { Authorization: `ApiKey ${context.profile.personal_api_key}`, "X-CDO-Client": "cdo-cli", "X-CDO-Version": VERSION },
|
|
229
|
+
redirect: "error", signal: AbortSignal.timeout(10_000),
|
|
230
|
+
}), "Git 状态读取失败");
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return { status: "control_unavailable", tea_api_ready: false, git_ssh_ready: false, error: error.message };
|
|
233
|
+
}
|
|
234
|
+
let sshHost;
|
|
235
|
+
try {
|
|
236
|
+
validateGitReadiness(facts, context.environmentName);
|
|
237
|
+
sshHost = `cdo-${safeProfileIdentity(gitProfileId(context, facts))}`;
|
|
238
|
+
} catch {
|
|
239
|
+
return { status: "control_invalid", tea_api_ready: false, git_ssh_ready: false, error: "Git 状态响应无效" };
|
|
240
|
+
}
|
|
241
|
+
if (context.profile.enterprise?.id && context.profile.enterprise.id !== facts.enterprise_id) {
|
|
242
|
+
return { status: "control_invalid", tea_api_ready: false, git_ssh_ready: false, error: "Git 企业身份与 CDO 登录不一致" };
|
|
243
|
+
}
|
|
244
|
+
const teaLogin = `cdo-${safeProfileIdentity(gitProfileId(context, facts))}`;
|
|
245
|
+
const teaResult = run("tea", ["api", "--login", teaLogin, "/user"], { encoding: "utf8", timeout: 10_000 });
|
|
246
|
+
const sshResult = run("ssh", ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", sshHost], { encoding: "utf8", timeout: 15_000 });
|
|
247
|
+
const teaBindingReady = await teaLoginMatches({
|
|
248
|
+
environment, home, name: teaLogin, url: facts.gitea_url, user: facts.username,
|
|
249
|
+
});
|
|
250
|
+
const teaApiReady = teaBindingReady && authenticatedTea(teaResult, facts.username);
|
|
251
|
+
const gitSshReady = authenticatedSSH(sshResult, facts.username);
|
|
252
|
+
return {
|
|
253
|
+
status: teaApiReady && gitSshReady ? "authenticated" : "not_authenticated",
|
|
254
|
+
tea_login: teaLogin,
|
|
255
|
+
tea_api_ready: teaApiReady,
|
|
256
|
+
git_ssh_ready: gitSshReady,
|
|
257
|
+
enterprise_id: facts.enterprise_id,
|
|
258
|
+
username: facts.username,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function gitAuth({ context, openBrowser, environment = process.env, home = homedir(), fetchImplementation = fetch, executeImplementation = execute }) {
|
|
263
|
+
await executeImplementation("git", ["--version"]);
|
|
264
|
+
await executeImplementation("tea", ["--version"]);
|
|
265
|
+
const control = validateGitControl(
|
|
266
|
+
environment.CDO_GIT_API_URL || GIT_CONTROLS[context.environmentName],
|
|
267
|
+
context.environmentName,
|
|
268
|
+
environment.CDO_GIT_API_URL ? "CDO_GIT_API_URL" : "Git 控制面地址",
|
|
269
|
+
);
|
|
270
|
+
const requestApi = async (path, init = {}) => responseJson(await fetchImplementation(new URL(path, control), {
|
|
271
|
+
...init, headers: { Authorization: `ApiKey ${context.profile.personal_api_key}`, "X-CDO-Client": "cdo-cli", "X-CDO-Version": VERSION, "Content-Type": "application/json", ...(init.headers || {}) },
|
|
272
|
+
redirect: "error", signal: AbortSignal.timeout(20_000),
|
|
273
|
+
}), "Git 身份验证失败");
|
|
274
|
+
const expected = await requestApi("/api/v1/git/readiness");
|
|
275
|
+
validateGitReadiness(expected, context.environmentName);
|
|
276
|
+
if (context.profile.enterprise?.id && context.profile.enterprise.id !== expected.enterprise_id) throw new Error("CDO 企业身份改变,请重新登录");
|
|
277
|
+
const profileId = gitProfileId(context, expected);
|
|
278
|
+
const locations = gitLocations(profileId, home);
|
|
279
|
+
await mkdir(dirname(locations.key), { recursive: true, mode: 0o700 });
|
|
280
|
+
if (!(await regularFile(locations.key))) await executeImplementation("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", locations.key]);
|
|
281
|
+
await chmod(locations.key, 0o600);
|
|
282
|
+
const publicKey = (await executeImplementation("ssh-keygen", ["-y", "-f", locations.key])).stdout.trim();
|
|
283
|
+
const state = randomBytes(32).toString("base64url");
|
|
284
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
285
|
+
let facts;
|
|
286
|
+
let callback;
|
|
287
|
+
const code = await new Promise((resolve, reject) => {
|
|
288
|
+
let settled = false;
|
|
289
|
+
const server = createServer((request, response) => {
|
|
290
|
+
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
291
|
+
const returned = url.searchParams.get("state") || "";
|
|
292
|
+
if (request.method !== "GET" || url.pathname !== "/" || returned.length !== state.length || !timingSafeEqual(Buffer.from(returned), Buffer.from(state))) {
|
|
293
|
+
response.writeHead(400).end("invalid OAuth callback"); return;
|
|
294
|
+
}
|
|
295
|
+
const value = url.searchParams.get("code");
|
|
296
|
+
if (!value) { response.writeHead(400).end("OAuth authorization denied"); finish(new Error("Git OAuth 授权未完成")); return; }
|
|
297
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }).end("<!doctype html><meta charset=utf-8><title>CDO Git 已授权</title><p>可以关闭此页面并返回终端。</p>");
|
|
298
|
+
finish(null, value);
|
|
299
|
+
});
|
|
300
|
+
const timer = setTimeout(() => finish(new Error("Git OAuth 等待授权超时")), 300_000);
|
|
301
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); server.close(); error ? reject(error) : resolve(value); };
|
|
302
|
+
server.on("error", finish);
|
|
303
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
304
|
+
try {
|
|
305
|
+
callback = `http://127.0.0.1:${server.address().port}/`;
|
|
306
|
+
facts = await requestApi("/api/v1/git/auth", { method: "POST", body: JSON.stringify({ public_key: publicKey, redirect_uri: callback, state, code_challenge: createHash("sha256").update(verifier).digest("base64url") }) });
|
|
307
|
+
validateGitReadiness(facts, context.environmentName);
|
|
308
|
+
if (facts.enterprise_id !== expected.enterprise_id || facts.username !== expected.username || facts.gitea_url !== expected.gitea_url) throw new Error("Git 认证企业不一致");
|
|
309
|
+
const browser = new URL(facts.browser_url);
|
|
310
|
+
if (browser.origin !== new URL(control).origin || browser.pathname !== "/api/v1/git/start") throw new Error("Git 认证入口不属于当前控制面");
|
|
311
|
+
await openBrowser(browser.toString());
|
|
312
|
+
} catch (error) { finish(error); }
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
const tokens = await responseJson(await fetchImplementation(`${facts.gitea_url}/login/oauth/access_token`, {
|
|
316
|
+
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, redirect: "error", signal: AbortSignal.timeout(20_000),
|
|
317
|
+
body: new URLSearchParams({ grant_type: "authorization_code", client_id: facts.client_id, redirect_uri: callback, code_verifier: verifier, code }),
|
|
318
|
+
}), "Git OAuth 交换失败");
|
|
319
|
+
if (!tokens.access_token || !tokens.refresh_token || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0) throw new Error("Git OAuth 响应无效");
|
|
320
|
+
const headers = { Authorization: `Bearer ${tokens.access_token}` };
|
|
321
|
+
const user = await responseJson(await fetchImplementation(`${facts.gitea_url}/api/v1/user`, { headers }), "Git 身份复验失败");
|
|
322
|
+
const member = await fetchImplementation(`${facts.gitea_url}/api/v1/orgs/${facts.organization}/members/${facts.username}`, { headers });
|
|
323
|
+
if (user.login !== facts.username || user.id !== facts.id || user.is_admin || member.status !== 204) throw new Error("Gitea 身份与 CDO 企业员工不一致,未保存凭据");
|
|
324
|
+
const current = await requestApi("/api/v1/git/readiness");
|
|
325
|
+
if (current.username !== facts.username || current.enterprise_id !== facts.enterprise_id) throw new Error("CDO 身份已改变");
|
|
326
|
+
const sshHostAlias = `cdo-${safeProfileIdentity(profileId)}`;
|
|
327
|
+
await withFileLock(locations.sshConfig, async () => {
|
|
328
|
+
const existing = await regularFile(locations.sshConfig) ? await readFile(locations.sshConfig, "utf8") : "";
|
|
329
|
+
await atomicPrivate(locations.sshConfig, sshBlock(existing, profileId, facts.ssh_url, locations.key));
|
|
330
|
+
});
|
|
331
|
+
const teaLogin = `cdo-${safeProfileIdentity(profileId)}`;
|
|
332
|
+
await saveTea(await teaConfigPath(environment, home), { name: teaLogin, url: facts.gitea_url, user: facts.username, ssh_host: sshHostAlias, ssh_key: locations.key }, tokens);
|
|
333
|
+
const teaResult = await executeImplementation("tea", ["api", "--login", teaLogin, "/user"]);
|
|
334
|
+
if (!authenticatedTea(teaResult, facts.username)) throw new Error("Tea 身份复验失败");
|
|
335
|
+
let sshResult;
|
|
336
|
+
try {
|
|
337
|
+
sshResult = await executeImplementation("ssh", ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", sshHostAlias]);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
sshResult = error;
|
|
340
|
+
}
|
|
341
|
+
if (!authenticatedSSH(sshResult, facts.username)) throw new Error("Git SSH 身份复验失败");
|
|
342
|
+
return { ...current, profile_id: profileId, tea_login: teaLogin, ssh_host_alias: sshHostAlias, git_ssh_configured: true, git_ssh_ready: true, tea_api_ready: true };
|
|
343
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
apiOriginForEnvironment,
|
|
6
|
+
endpointForEnvironment,
|
|
7
|
+
upsertProfile,
|
|
8
|
+
validateEnterpriseCode,
|
|
9
|
+
validateEndpointEnvironment,
|
|
10
|
+
validateEnvironment,
|
|
11
|
+
} from "./config.mjs";
|
|
12
|
+
|
|
13
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function sameSecret(left, right) {
|
|
16
|
+
const one = Buffer.from(left || "");
|
|
17
|
+
const two = Buffer.from(right || "");
|
|
18
|
+
return one.length === two.length && timingSafeEqual(one, two);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function completePage() {
|
|
22
|
+
return '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>CDO CLI 已授权</title><body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#faf9f5;font:17px system-ui"><main style="padding:40px;background:white;border:1px solid #e8e6dc;border-radius:16px;text-align:center"><h1>CDO CLI 已授权</h1><p>可以关闭此页面并返回终端。</p></main></body></html>';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function responseError(response, body) {
|
|
26
|
+
return body?.error?.message || body?.message || `登录交换失败 (${response.status})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Validate the complete server identity envelope before atomically persisting a user Key. */
|
|
30
|
+
export async function persistLoginResult(result, {
|
|
31
|
+
normalizedEndpoint,
|
|
32
|
+
selectedEnvironment,
|
|
33
|
+
selectedEnterprise,
|
|
34
|
+
setDefault = false,
|
|
35
|
+
environment = process.env,
|
|
36
|
+
}) {
|
|
37
|
+
validateEndpointEnvironment(normalizedEndpoint, selectedEnvironment, "登录 API 地址");
|
|
38
|
+
let returnedEndpoint;
|
|
39
|
+
try {
|
|
40
|
+
returnedEndpoint = validateEndpointEnvironment(
|
|
41
|
+
result?.api_base_url,
|
|
42
|
+
selectedEnvironment,
|
|
43
|
+
"登录响应 API 地址",
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("登录交换身份、环境或 Key 响应无效,未保存配置");
|
|
47
|
+
}
|
|
48
|
+
const returnedApiBaseUrl = returnedEndpoint.toString().replace(/\/$/, "");
|
|
49
|
+
const allowedReturnedOrigins = [
|
|
50
|
+
endpointForEnvironment(selectedEnvironment, selectedEnterprise),
|
|
51
|
+
apiOriginForEnvironment(selectedEnvironment),
|
|
52
|
+
];
|
|
53
|
+
if (!result?.api_key || !result?.key_id
|
|
54
|
+
|| !allowedReturnedOrigins.includes(returnedApiBaseUrl)
|
|
55
|
+
|| result.enterprise?.abbr !== selectedEnterprise || !result.enterprise?.id || !result.user?.id) {
|
|
56
|
+
throw new Error("登录交换身份、环境或 Key 响应无效,未保存配置");
|
|
57
|
+
}
|
|
58
|
+
const profile = await upsertProfile({
|
|
59
|
+
environment: selectedEnvironment,
|
|
60
|
+
api_base_url: returnedApiBaseUrl,
|
|
61
|
+
enterprise: result.enterprise,
|
|
62
|
+
user: result.user,
|
|
63
|
+
key_id: result.key_id,
|
|
64
|
+
personal_api_key: result.api_key,
|
|
65
|
+
}, { setDefault, environment });
|
|
66
|
+
result.profile_id = profile.id;
|
|
67
|
+
result.profile_alias = profile.alias;
|
|
68
|
+
result.api_base_url = returnedApiBaseUrl;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Run the browser/loopback PKCE handoff and persist only the returned user Key. */
|
|
73
|
+
export async function login({
|
|
74
|
+
environmentName,
|
|
75
|
+
enterpriseCode,
|
|
76
|
+
apiBaseUrl = null,
|
|
77
|
+
setDefault = false,
|
|
78
|
+
environment = process.env,
|
|
79
|
+
openBrowser,
|
|
80
|
+
fetchImplementation = fetch,
|
|
81
|
+
write = (value) => process.stdout.write(value),
|
|
82
|
+
}) {
|
|
83
|
+
const selectedEnvironment = validateEnvironment(environmentName);
|
|
84
|
+
const selectedEnterprise = validateEnterpriseCode(enterpriseCode);
|
|
85
|
+
const endpoint = validateEndpointEnvironment(
|
|
86
|
+
apiBaseUrl || endpointForEnvironment(selectedEnvironment, selectedEnterprise),
|
|
87
|
+
selectedEnvironment,
|
|
88
|
+
"--api-url",
|
|
89
|
+
);
|
|
90
|
+
endpoint.pathname = "/";
|
|
91
|
+
endpoint.search = "";
|
|
92
|
+
endpoint.hash = "";
|
|
93
|
+
const normalizedEndpoint = endpoint.toString().replace(/\/$/, "");
|
|
94
|
+
// The API may use the shared official origin; interactive login always belongs to the selected enterprise host.
|
|
95
|
+
const browserEndpoint = endpointForEnvironment(selectedEnvironment, selectedEnterprise);
|
|
96
|
+
if (![browserEndpoint, apiOriginForEnvironment(selectedEnvironment)].includes(normalizedEndpoint)) {
|
|
97
|
+
throw new Error("--api-url 与 --enterprise 不匹配");
|
|
98
|
+
}
|
|
99
|
+
const state = randomBytes(32).toString("base64url");
|
|
100
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
101
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
102
|
+
|
|
103
|
+
const { code, redirectUri } = await new Promise((resolve, reject) => {
|
|
104
|
+
let settled = false;
|
|
105
|
+
const server = createServer((request, response) => {
|
|
106
|
+
const callback = new URL(request.url || "/", "http://127.0.0.1");
|
|
107
|
+
const returnedState = callback.searchParams.get("state") || "";
|
|
108
|
+
if (request.method !== "GET" || callback.pathname !== "/auth/callback" || !sameSecret(returnedState, state)) {
|
|
109
|
+
response.writeHead(400, { "Cache-Control": "no-store" }).end("invalid login callback");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const returnedCode = callback.searchParams.get("code");
|
|
113
|
+
if (!returnedCode) {
|
|
114
|
+
response.writeHead(400, { "Cache-Control": "no-store" }).end("missing authorization code");
|
|
115
|
+
finish(new Error("登录回调缺少一次性 code"));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
119
|
+
response.end(completePage());
|
|
120
|
+
finish(null, { code: returnedCode, redirectUri });
|
|
121
|
+
});
|
|
122
|
+
const timer = setTimeout(() => finish(new Error("等待浏览器登录超时")), LOGIN_TIMEOUT_MS);
|
|
123
|
+
const finish = (error, value) => {
|
|
124
|
+
if (settled) return;
|
|
125
|
+
settled = true;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
server.close();
|
|
128
|
+
error ? reject(error) : resolve(value);
|
|
129
|
+
};
|
|
130
|
+
let redirectUri;
|
|
131
|
+
server.on("error", finish);
|
|
132
|
+
server.listen(0, "127.0.0.1", async () => {
|
|
133
|
+
try {
|
|
134
|
+
redirectUri = `http://127.0.0.1:${server.address().port}/auth/callback`;
|
|
135
|
+
const authorizeUrl = new URL("/cli/authorize", browserEndpoint);
|
|
136
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
137
|
+
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
|
|
138
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
139
|
+
authorizeUrl.searchParams.set("state", state);
|
|
140
|
+
await openBrowser(authorizeUrl.toString());
|
|
141
|
+
write(`请在浏览器完成 ${selectedEnterprise} 的 CDO 登录…\n`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
finish(error);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const response = await fetchImplementation(new URL("/api/v1/auth/cli-key-exchanges", browserEndpoint), {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
redirect: "error",
|
|
152
|
+
signal: AbortSignal.timeout(20_000),
|
|
153
|
+
body: JSON.stringify({ code, redirect_uri: redirectUri, code_verifier: codeVerifier }),
|
|
154
|
+
});
|
|
155
|
+
let envelope;
|
|
156
|
+
try {
|
|
157
|
+
envelope = await response.json();
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error("登录交换响应不是有效 JSON");
|
|
160
|
+
}
|
|
161
|
+
if (!response.ok) throw new Error(responseError(response, envelope));
|
|
162
|
+
const result = await persistLoginResult(envelope?.data, {
|
|
163
|
+
normalizedEndpoint, selectedEnvironment, selectedEnterprise, setDefault, environment,
|
|
164
|
+
});
|
|
165
|
+
write(`CDO 登录成功:${result.enterprise.name}(${result.enterprise.abbr}),用户 ${result.user.name || result.user.id};profile ${result.profile_alias} (${result.profile_id})。\n`);
|
|
166
|
+
return result;
|
|
167
|
+
}
|