@bigbrain-work/mcp-connect 1.2.2 → 1.3.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/LICENSE +21 -0
- package/README.md +33 -59
- package/bin/mcp-connect.js +4 -4
- package/bin/shiliu.js +8 -0
- package/package.json +9 -5
- package/src/arguments.js +75 -19
- package/src/authorization.js +49 -0
- package/src/cli.js +424 -73
- package/src/configurators.js +118 -137
- package/src/constants.js +12 -6
- package/src/credentials.js +166 -148
- package/src/detection.js +16 -21
- package/src/device-auth-client.js +130 -0
- package/src/login-flow.js +39 -0
- package/src/proxy.js +59 -58
- package/src/remote-client.js +27 -10
- package/src/security.js +63 -0
- package/src/status.js +117 -58
- package/src/token-store.js +116 -0
- package/src/updater.js +59 -0
package/src/credentials.js
CHANGED
|
@@ -1,160 +1,151 @@
|
|
|
1
|
-
import { spawn } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (/\s/.test(apiKey)) {
|
|
15
|
-
throw new Error('API Key 不能包含空白字符')
|
|
16
|
-
}
|
|
17
|
-
if (apiKey.length < 12) {
|
|
18
|
-
throw new Error('API Key 长度异常,请确认复制完整')
|
|
19
|
-
}
|
|
20
|
-
return apiKey
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { API_KEY_ENV } from "./constants.js";
|
|
7
|
+
|
|
8
|
+
export function validateApiKey(value) {
|
|
9
|
+
const apiKey = value?.trim();
|
|
10
|
+
if (!apiKey) throw new Error("API Key 不能为空");
|
|
11
|
+
if (/\s/u.test(apiKey)) throw new Error("API Key 不能包含空白字符");
|
|
12
|
+
if (apiKey.length < 12) throw new Error("API Key 长度异常,请确认复制完整");
|
|
13
|
+
return apiKey;
|
|
21
14
|
}
|
|
22
15
|
|
|
23
|
-
export async function readHiddenInput(
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
export async function readHiddenInput(
|
|
17
|
+
promptText,
|
|
18
|
+
input = process.stdin,
|
|
19
|
+
output = process.stdout,
|
|
20
|
+
) {
|
|
21
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`当前终端不支持隐藏输入,请先设置 ${API_KEY_ENV} 环境变量后重试`,
|
|
24
|
+
);
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
output.write(promptText)
|
|
29
|
-
input.setRawMode(true)
|
|
30
|
-
input.resume()
|
|
31
|
-
input.setEncoding(
|
|
27
|
+
output.write(promptText);
|
|
28
|
+
input.setRawMode(true);
|
|
29
|
+
input.resume();
|
|
30
|
+
input.setEncoding("utf8");
|
|
32
31
|
|
|
33
32
|
return new Promise((resolve, reject) => {
|
|
34
|
-
let value =
|
|
35
|
-
|
|
33
|
+
let value = "";
|
|
36
34
|
const cleanup = () => {
|
|
37
|
-
input.off(
|
|
38
|
-
input.setRawMode(false)
|
|
39
|
-
input.pause()
|
|
40
|
-
}
|
|
41
|
-
|
|
35
|
+
input.off("data", onData);
|
|
36
|
+
input.setRawMode(false);
|
|
37
|
+
input.pause();
|
|
38
|
+
};
|
|
42
39
|
const onData = (chunk) => {
|
|
43
40
|
for (const character of chunk) {
|
|
44
|
-
if (character ===
|
|
45
|
-
cleanup()
|
|
46
|
-
output.write(
|
|
47
|
-
reject(new Error(
|
|
48
|
-
return
|
|
41
|
+
if (character === "\u0003") {
|
|
42
|
+
cleanup();
|
|
43
|
+
output.write("\n");
|
|
44
|
+
reject(new Error("操作已取消"));
|
|
45
|
+
return;
|
|
49
46
|
}
|
|
50
|
-
if (character ===
|
|
51
|
-
cleanup()
|
|
52
|
-
output.write(
|
|
53
|
-
resolve(value)
|
|
54
|
-
return
|
|
47
|
+
if (character === "\r" || character === "\n") {
|
|
48
|
+
cleanup();
|
|
49
|
+
output.write("\n");
|
|
50
|
+
resolve(value);
|
|
51
|
+
return;
|
|
55
52
|
}
|
|
56
|
-
if (character ===
|
|
57
|
-
value = value.slice(0, -1)
|
|
58
|
-
continue
|
|
53
|
+
if (character === "\u007f" || character === "\b") {
|
|
54
|
+
value = value.slice(0, -1);
|
|
55
|
+
continue;
|
|
59
56
|
}
|
|
60
|
-
value += character
|
|
57
|
+
value += character;
|
|
61
58
|
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
})
|
|
59
|
+
};
|
|
60
|
+
input.on("data", onData);
|
|
61
|
+
});
|
|
66
62
|
}
|
|
67
63
|
|
|
68
|
-
export async function resolveApiKey({
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return validateApiKey(
|
|
64
|
+
export async function resolveApiKey({
|
|
65
|
+
env = process.env,
|
|
66
|
+
prompt = readHiddenInput,
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (env[API_KEY_ENV]) return validateApiKey(env[API_KEY_ENV]);
|
|
69
|
+
return validateApiKey(
|
|
70
|
+
await prompt("请输入石榴 AI API Key(输入内容不会显示):"),
|
|
71
|
+
);
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
function runPowerShellWithInput(script, input) {
|
|
74
|
+
function runPowerShellWithInput(script, input = "") {
|
|
76
75
|
return new Promise((resolve, reject) => {
|
|
77
|
-
const child = spawn(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
child.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
} else {
|
|
98
|
-
reject(new Error(stderr.trim() || `PowerShell 返回退出码 ${code}`))
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
child.stdin.end(input)
|
|
102
|
-
})
|
|
76
|
+
const child = spawn(
|
|
77
|
+
"powershell.exe",
|
|
78
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
79
|
+
{
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
let stderr = "";
|
|
85
|
+
child.stderr.setEncoding("utf8");
|
|
86
|
+
child.stderr.on("data", (chunk) => {
|
|
87
|
+
stderr += chunk;
|
|
88
|
+
});
|
|
89
|
+
child.on("error", reject);
|
|
90
|
+
child.on("close", (code) => {
|
|
91
|
+
if (code === 0) resolve();
|
|
92
|
+
else reject(new Error(stderr.trim() || `PowerShell 返回退出码 ${code}`));
|
|
93
|
+
});
|
|
94
|
+
child.stdin.end(input);
|
|
95
|
+
});
|
|
103
96
|
}
|
|
104
97
|
|
|
105
98
|
function shellQuote(value) {
|
|
106
|
-
return `'${value.replaceAll("'", "'\\''")}'
|
|
99
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function posixPaths(home, env) {
|
|
103
|
+
const configDir = path.join(home, ".config", "shiliu-ai");
|
|
104
|
+
const envFile = path.join(configDir, "env");
|
|
105
|
+
const shellName = path.basename(env.SHELL || "");
|
|
106
|
+
const profileName =
|
|
107
|
+
shellName === "zsh"
|
|
108
|
+
? ".zprofile"
|
|
109
|
+
: shellName === "bash"
|
|
110
|
+
? ".bash_profile"
|
|
111
|
+
: ".profile";
|
|
112
|
+
return { configDir, envFile, profilePath: path.join(home, profileName) };
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
async function persistPosixApiKey(apiKey, home, env) {
|
|
110
|
-
const configDir =
|
|
111
|
-
|
|
112
|
-
await mkdir(configDir, { recursive: true })
|
|
116
|
+
const { configDir, envFile, profilePath } = posixPaths(home, env);
|
|
117
|
+
await mkdir(configDir, { recursive: true });
|
|
113
118
|
await writeFile(envFile, `export ${API_KEY_ENV}=${shellQuote(apiKey)}\n`, {
|
|
114
|
-
encoding:
|
|
119
|
+
encoding: "utf8",
|
|
115
120
|
mode: 0o600,
|
|
116
|
-
})
|
|
117
|
-
await chmod(envFile, 0o600)
|
|
121
|
+
});
|
|
122
|
+
await chmod(envFile, 0o600);
|
|
118
123
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
? '.zprofile'
|
|
122
|
-
: shellName === 'bash'
|
|
123
|
-
? '.bash_profile'
|
|
124
|
-
: '.profile'
|
|
125
|
-
const profilePath = path.join(home, profileName)
|
|
126
|
-
const sourceLine = `. ${shellQuote(envFile)} # shiliu-ai\n`
|
|
127
|
-
let profile = ''
|
|
124
|
+
const sourceLine = `. ${shellQuote(envFile)} # shiliu-ai\n`;
|
|
125
|
+
let profile = "";
|
|
128
126
|
try {
|
|
129
|
-
profile = await readFile(profilePath,
|
|
127
|
+
profile = await readFile(profilePath, "utf8");
|
|
130
128
|
} catch (error) {
|
|
131
|
-
if (error.code !==
|
|
132
|
-
throw error
|
|
133
|
-
}
|
|
129
|
+
if (error.code !== "ENOENT") throw error;
|
|
134
130
|
}
|
|
135
|
-
if (!profile.includes(
|
|
136
|
-
const separator = profile && !profile.endsWith(
|
|
137
|
-
await writeFile(profilePath, `${profile}${separator}${sourceLine}`,
|
|
131
|
+
if (!profile.includes("# shiliu-ai")) {
|
|
132
|
+
const separator = profile && !profile.endsWith("\n") ? "\n" : "";
|
|
133
|
+
await writeFile(profilePath, `${profile}${separator}${sourceLine}`, "utf8");
|
|
138
134
|
}
|
|
139
135
|
}
|
|
140
136
|
|
|
141
|
-
export async function persistApiKey(
|
|
142
|
-
|
|
143
|
-
home,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (platform === 'win32') {
|
|
152
|
-
const script = `$value = [Console]::In.ReadToEnd(); [Environment]::SetEnvironmentVariable('${API_KEY_ENV}', $value, 'User')`
|
|
153
|
-
await runPowerShellWithInput(script, apiKey)
|
|
137
|
+
export async function persistApiKey(
|
|
138
|
+
apiKey,
|
|
139
|
+
{ dryRun = false, home, platform = process.platform, env = process.env } = {},
|
|
140
|
+
) {
|
|
141
|
+
if (dryRun) return;
|
|
142
|
+
if (platform === "win32") {
|
|
143
|
+
const script = `$value = [Console]::In.ReadToEnd(); [Environment]::SetEnvironmentVariable('${API_KEY_ENV}', $value, 'User')`;
|
|
144
|
+
await runPowerShellWithInput(script, apiKey);
|
|
154
145
|
} else {
|
|
155
|
-
await persistPosixApiKey(apiKey, home, env)
|
|
146
|
+
await persistPosixApiKey(apiKey, home, env);
|
|
156
147
|
}
|
|
157
|
-
process.env[API_KEY_ENV] = apiKey
|
|
148
|
+
process.env[API_KEY_ENV] = apiKey;
|
|
158
149
|
}
|
|
159
150
|
|
|
160
151
|
export function readPersistedApiKey({
|
|
@@ -162,37 +153,64 @@ export function readPersistedApiKey({
|
|
|
162
153
|
env = process.env,
|
|
163
154
|
home,
|
|
164
155
|
} = {}) {
|
|
165
|
-
if (env[API_KEY_ENV])
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
})
|
|
180
|
-
return result.status === 0 ? result.stdout.trim() : ''
|
|
156
|
+
if (env[API_KEY_ENV]) return env[API_KEY_ENV];
|
|
157
|
+
if (platform === "win32") {
|
|
158
|
+
const result = spawnSync(
|
|
159
|
+
"powershell.exe",
|
|
160
|
+
[
|
|
161
|
+
"-NoLogo",
|
|
162
|
+
"-NoProfile",
|
|
163
|
+
"-NonInteractive",
|
|
164
|
+
"-Command",
|
|
165
|
+
`[Console]::Out.Write([Environment]::GetEnvironmentVariable('${API_KEY_ENV}', 'User'))`,
|
|
166
|
+
],
|
|
167
|
+
{ windowsHide: true, encoding: "utf8" },
|
|
168
|
+
);
|
|
169
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
181
170
|
}
|
|
182
171
|
|
|
183
172
|
try {
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
173
|
+
const { envFile } = posixPaths(home, env);
|
|
174
|
+
const content = readFileSync(envFile, "utf8");
|
|
175
|
+
const prefix = `export ${API_KEY_ENV}=`;
|
|
176
|
+
const line = content
|
|
177
|
+
.split(/\r?\n/u)
|
|
178
|
+
.find((item) => item.startsWith(prefix));
|
|
179
|
+
if (!line) return "";
|
|
180
|
+
const encodedValue = line.slice(prefix.length);
|
|
191
181
|
if (encodedValue.startsWith("'") && encodedValue.endsWith("'")) {
|
|
192
|
-
return encodedValue.slice(1, -1).replaceAll("'\\''", "'")
|
|
182
|
+
return encodedValue.slice(1, -1).replaceAll("'\\''", "'");
|
|
193
183
|
}
|
|
194
|
-
return encodedValue
|
|
184
|
+
return encodedValue;
|
|
195
185
|
} catch {
|
|
196
|
-
return
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function clearPersistedApiKey({
|
|
191
|
+
home,
|
|
192
|
+
platform = process.platform,
|
|
193
|
+
env = process.env,
|
|
194
|
+
dryRun = false,
|
|
195
|
+
} = {}) {
|
|
196
|
+
if (dryRun) return;
|
|
197
|
+
if (platform === "win32") {
|
|
198
|
+
await runPowerShellWithInput(
|
|
199
|
+
`[Environment]::SetEnvironmentVariable('${API_KEY_ENV}', $null, 'User')`,
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
const { envFile, profilePath } = posixPaths(home, env);
|
|
203
|
+
await rm(envFile, { force: true });
|
|
204
|
+
try {
|
|
205
|
+
const profile = await readFile(profilePath, "utf8");
|
|
206
|
+
const next = profile
|
|
207
|
+
.split(/(?<=\n)/u)
|
|
208
|
+
.filter((line) => !line.includes("# shiliu-ai"))
|
|
209
|
+
.join("");
|
|
210
|
+
if (next !== profile) await writeFile(profilePath, next, "utf8");
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code !== "ENOENT") throw error;
|
|
213
|
+
}
|
|
197
214
|
}
|
|
215
|
+
delete process.env[API_KEY_ENV];
|
|
198
216
|
}
|
package/src/detection.js
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
export function commandExists(command, spawn = spawnSync) {
|
|
7
|
-
const result = spawn(command, [
|
|
8
|
-
encoding:
|
|
6
|
+
const result = spawn(command, ["--version"], {
|
|
7
|
+
encoding: "utf8",
|
|
9
8
|
windowsHide: true,
|
|
10
9
|
shell: false,
|
|
11
10
|
timeout: 5000,
|
|
12
|
-
})
|
|
13
|
-
return !result.error && result.status === 0
|
|
11
|
+
});
|
|
12
|
+
return !result.error && result.status === 0;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
async function pathExists(targetPath) {
|
|
17
16
|
try {
|
|
18
|
-
await access(targetPath)
|
|
19
|
-
return true
|
|
17
|
+
await access(targetPath);
|
|
18
|
+
return true;
|
|
20
19
|
} catch {
|
|
21
|
-
return false
|
|
20
|
+
return false;
|
|
22
21
|
}
|
|
23
22
|
}
|
|
24
23
|
|
|
@@ -27,15 +26,11 @@ export async function detectInstalledAgents({
|
|
|
27
26
|
hasCommand = commandExists,
|
|
28
27
|
exists = pathExists,
|
|
29
28
|
} = {}) {
|
|
30
|
-
const agents = []
|
|
31
|
-
if (hasCommand(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
agents.push('claude')
|
|
36
|
-
}
|
|
37
|
-
if (hasCommand('cursor') || await exists(path.join(home, '.cursor'))) {
|
|
38
|
-
agents.push('cursor')
|
|
29
|
+
const agents = [];
|
|
30
|
+
if (hasCommand("codex")) agents.push("codex");
|
|
31
|
+
if (hasCommand("claude")) agents.push("claude");
|
|
32
|
+
if (hasCommand("cursor") || (await exists(path.join(home, ".cursor")))) {
|
|
33
|
+
agents.push("cursor");
|
|
39
34
|
}
|
|
40
|
-
return agents
|
|
35
|
+
return agents;
|
|
41
36
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import QRCode from "qrcode";
|
|
2
|
+
|
|
3
|
+
import { AUTH_URL } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
export class DeviceAuthError extends Error {
|
|
6
|
+
constructor(code, message, status) {
|
|
7
|
+
super(message || code);
|
|
8
|
+
this.name = "DeviceAuthError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function requestJson(url, options, fetchImpl) {
|
|
15
|
+
const response = await fetchImpl(url, {
|
|
16
|
+
...options,
|
|
17
|
+
headers: {
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
...(options.headers || {}),
|
|
21
|
+
},
|
|
22
|
+
signal: options.signal || AbortSignal.timeout(15000),
|
|
23
|
+
});
|
|
24
|
+
const payload = await response.json().catch(() => ({}));
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
const body = payload.response || payload;
|
|
27
|
+
throw new DeviceAuthError(
|
|
28
|
+
body.error || "request_failed",
|
|
29
|
+
body.error_description || body.message || `HTTP ${response.status}`,
|
|
30
|
+
response.status,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return payload;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class DeviceAuthClient {
|
|
37
|
+
constructor({ baseUrl = AUTH_URL, fetchImpl = fetch } = {}) {
|
|
38
|
+
this.baseUrl = baseUrl.replace(/\/$/u, "");
|
|
39
|
+
this.fetchImpl = fetchImpl;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
start() {
|
|
43
|
+
return requestJson(
|
|
44
|
+
`${this.baseUrl}/code`,
|
|
45
|
+
{ method: "POST" },
|
|
46
|
+
this.fetchImpl,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
exchange(deviceCode) {
|
|
51
|
+
return requestJson(
|
|
52
|
+
`${this.baseUrl}/token`,
|
|
53
|
+
{ method: "POST", body: JSON.stringify({ device_code: deviceCode }) },
|
|
54
|
+
this.fetchImpl,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
refresh(refreshToken) {
|
|
59
|
+
return requestJson(
|
|
60
|
+
`${this.baseUrl}/refresh`,
|
|
61
|
+
{ method: "POST", body: JSON.stringify({ refresh_token: refreshToken }) },
|
|
62
|
+
this.fetchImpl,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
revoke(refreshToken) {
|
|
67
|
+
return requestJson(
|
|
68
|
+
`${this.baseUrl}/revoke`,
|
|
69
|
+
{ method: "POST", body: JSON.stringify({ refresh_token: refreshToken }) },
|
|
70
|
+
this.fetchImpl,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeTokenResponse(response, now = Date.now()) {
|
|
76
|
+
if (
|
|
77
|
+
typeof response?.access_token !== "string" ||
|
|
78
|
+
typeof response?.refresh_token !== "string" ||
|
|
79
|
+
!Number.isFinite(Number(response?.expires_in))
|
|
80
|
+
) {
|
|
81
|
+
throw new Error("授权服务返回的令牌格式无效");
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
accessToken: response.access_token,
|
|
85
|
+
refreshToken: response.refresh_token,
|
|
86
|
+
expiresAt: now + Number(response.expires_in) * 1000,
|
|
87
|
+
tokenType: response.token_type || "Bearer",
|
|
88
|
+
scope: response.scope || "mcp",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function renderQrCode(value) {
|
|
93
|
+
return QRCode.toString(value, {
|
|
94
|
+
type: "terminal",
|
|
95
|
+
small: true,
|
|
96
|
+
errorCorrectionLevel: "M",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultSleep(milliseconds) {
|
|
101
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function waitForDeviceAuthorization(
|
|
105
|
+
start,
|
|
106
|
+
client,
|
|
107
|
+
{ sleep = defaultSleep, now = Date.now, onPending = () => {} } = {},
|
|
108
|
+
) {
|
|
109
|
+
const deadline = now() + Number(start.expires_in) * 1000;
|
|
110
|
+
let intervalSeconds = Number(start.interval) || 5;
|
|
111
|
+
|
|
112
|
+
while (now() < deadline) {
|
|
113
|
+
await sleep(intervalSeconds * 1000);
|
|
114
|
+
try {
|
|
115
|
+
return await client.exchange(start.device_code);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!(error instanceof DeviceAuthError)) throw error;
|
|
118
|
+
if (error.code === "authorization_pending") {
|
|
119
|
+
onPending();
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (error.code === "slow_down") {
|
|
123
|
+
intervalSeconds += 5;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new DeviceAuthError("expired_token", "登录二维码已过期,请重试");
|
|
130
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { normalizeTokenResponse } from "./device-auth-client.js";
|
|
2
|
+
import { probeMcp } from "./status.js";
|
|
3
|
+
|
|
4
|
+
export async function verifyAndPersistLogin({
|
|
5
|
+
response,
|
|
6
|
+
client,
|
|
7
|
+
tokenStore,
|
|
8
|
+
url,
|
|
9
|
+
probe = probeMcp,
|
|
10
|
+
}) {
|
|
11
|
+
const tokenSet = normalizeTokenResponse(response);
|
|
12
|
+
const remote = await probe(tokenSet.accessToken, { url });
|
|
13
|
+
if (!remote.ok) {
|
|
14
|
+
const validationError = new Error(`登录令牌验证失败:${remote.detail}`);
|
|
15
|
+
try {
|
|
16
|
+
await client.revoke(tokenSet.refreshToken);
|
|
17
|
+
} catch (revokeError) {
|
|
18
|
+
throw new AggregateError(
|
|
19
|
+
[validationError, revokeError],
|
|
20
|
+
`登录令牌验证失败,且远端令牌撤销失败:${remote.detail}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
throw validationError;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await tokenStore.save(tokenSet);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
try {
|
|
29
|
+
await client.revoke(tokenSet.refreshToken);
|
|
30
|
+
} catch (revokeError) {
|
|
31
|
+
throw new AggregateError(
|
|
32
|
+
[error, revokeError],
|
|
33
|
+
`本机凭据保存失败,且远端令牌撤销失败:${error.message}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return remote;
|
|
39
|
+
}
|