tyc-cli 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,9 +61,9 @@ tyc login
61
61
  tyc login --url "https://ai-mcp-pre.tianyancha.com/mcp"
62
62
  tyc login --url "http://localhost:8080/mcp" --issuer "http://localhost:8080/oauth"
63
63
 
64
- # 非阻塞 OAuth:只打印授权 URL,不占住命令行;授权后手动传回 callback code
65
- tyc login --no-open --no-block
66
- tyc login --callback-token "<callback_code_or_full_callback_url>"
64
+ # 非阻塞 OAuth:打印授权 URL 和 6 位验证码;网页授权后恢复登录
65
+ tyc login --no-block
66
+ tyc login --resume
67
67
 
68
68
  # API key 兼容路径:已有天眼查 OpenAPI token 时可直接写入
69
69
  tyc init --authorization "YOUR_API_TOKEN"
@@ -77,10 +77,11 @@ tyc init --authorization "YOUR_API_TOKEN" --no-verify
77
77
  > `Authorization: Bearer <access_token>` 和 refresh token 上下文写入
78
78
  > `~/.tyc/config.json`。后续业务命令会在 access token 临期时自动刷新;
79
79
  > 如果服务端返回 401,会强制刷新一次并重试当前请求。
80
- > `tyc login --no-open --no-block` 会打印授权 URL 后立即退出,并把一次性 PKCE 上下文
81
- > 保存到 `~/.tyc/oauth_pending.json`;完成浏览器授权后,页面会跳转到天眼查 CLI
82
- > 回调页并展示可复制命令,执行 `tyc login --callback-token "<...>"` 完成换 token。
83
- > `tyc init` 保存配置后会立即校验 shared core endpoint;失败则退出码 1 并提示连通性问题。
80
+ > `tyc login --no-block` 使用 OAuth Device Flow,打印授权 URL 和 6 位验证码后立即退出,
81
+ > 并把一次性 `device_code` 保存到 `~/.tyc/oauth_pending.json`。在网页输入验证码并确认授权后,
82
+ > 执行 `tyc login --resume` 完成换 token。旧版 `tyc login --callback-token "<...>"` 仍保留作兼容入口。
83
+ > `tyc init --authorization` 保存配置后会立即校验 shared core auth ready;通过后输出与
84
+ > OAuth 登录一致的登录成功提示。失败则退出码 1 并提示配置校验问题。
84
85
  > 加 `--no-verify` 可跳过校验。
85
86
 
86
87
  配置存于 `~/.tyc/config.json`(权限 600):
@@ -122,8 +123,9 @@ tyc executive personnel-dishonest "..." --humanName "张三"
122
123
  | 命令 | 说明 |
123
124
  |------|------|
124
125
  | `tyc login` | 浏览器 OAuth 登录;自动发现 metadata、使用 PKCE 获取 access token / refresh token 并写入配置 |
125
- | `tyc login --no-open --no-block` | 打印 OAuth 授权 URL 并立即退出,授权完成后按天眼查 CLI 回调页提示继续 |
126
- | `tyc login --callback-token <code-or-url>` | 使用上一步保存的 PKCE 上下文,把回调 code 换成 access token |
126
+ | `tyc login --no-block` | 打印 Device Flow 授权 URL 和 6 位验证码并立即退出 |
127
+ | `tyc login --resume` | 网页输入验证码并确认授权后,使用本地 pending device_code 换取 access token |
128
+ | `tyc login --callback-token <code-or-url>` | 兼容旧版 PKCE 非阻塞登录,把回调 code 换成 access token |
127
129
  | `tyc login --url <url>` | 对指定 MCP endpoint 发起 OAuth 登录 |
128
130
  | `tyc init --authorization <token>` | 写入 `headers.Authorization`;保存后会立即校验 shared core 连通性 |
129
131
  | `tyc init --url <url>` | 设置 MCP endpoint |
@@ -1,9 +1,10 @@
1
- import { DEFAULT_MCP_URL, defaultCoreURL, loadConfig, resolveConfig, saveConfig, } from "../config.js";
2
- import { verifyCoreEndpoint } from "../coreClient.js";
1
+ import { DEFAULT_MCP_URL, defaultCoreURL, loadConfig, saveConfig, } from "../config.js";
2
+ import { verifyCoreAuthEndpoint, verifyCoreEndpoint } from "../coreClient.js";
3
+ import { printLoginSuccessBanner } from "../logo.js";
3
4
  export function registerInitCommand(program) {
4
5
  program
5
6
  .command("init")
6
- .description("配置 endpoint / Authorization(保存到 ~/.tyc/config.json,并校验 shared core 连通性)")
7
+ .description("配置 endpoint / Authorization(保存到 ~/.tyc/config.json,并校验配置)")
7
8
  .option("--authorization <token>", "tyc OpenAPI Authorization(写入 headers.Authorization)")
8
9
  .option("--url <url>", `MCP endpoint(默认 ${DEFAULT_MCP_URL})`)
9
10
  .option("--core-url <url>", "Shared Core HTTP endpoint(默认从 --url 推导 /v1/core/tools/call)")
@@ -52,21 +53,46 @@ export function registerInitCommand(program) {
52
53
  }
53
54
  if (resetOAuth)
54
55
  delete cfg.oauth;
56
+ const persistedAuthorization = cfg.headers.Authorization?.trim();
55
57
  saveConfig(cfg);
56
- console.log(`已保存 ~/.tyc/config.json (url=${cfg.url}, coreUrl=${cfg.coreUrl})`);
58
+ const verbose = !!program.opts().verbose;
59
+ if (verbose) {
60
+ console.error(`> config saved to ~/.tyc/config.json (url=${cfg.url}, coreUrl=${cfg.coreUrl})`);
61
+ }
57
62
  if (opts.verify === false) {
63
+ console.log(`已保存 ~/.tyc/config.json (url=${cfg.url}, coreUrl=${cfg.coreUrl})`);
58
64
  return;
59
65
  }
60
66
  try {
61
- const resolved = resolveConfig();
62
- const verbose = !!program.opts().verbose;
63
- await verifyCoreEndpoint(resolved, verbose);
64
- console.log("Shared Core endpoint 校验通过");
67
+ const verifyConfig = {
68
+ url: cfg.url,
69
+ coreUrl: cfg.coreUrl || defaultCoreURL(cfg.url),
70
+ headers: { ...(cfg.headers || {}) },
71
+ };
72
+ if (!verifyConfig.headers.Authorization && process.env.TYC_AUTHORIZATION) {
73
+ verifyConfig.headers.Authorization = process.env.TYC_AUTHORIZATION;
74
+ }
75
+ if (verifyConfig.headers.Authorization?.trim()) {
76
+ await verifyCoreAuthEndpoint(verifyConfig, verbose);
77
+ if (verbose) {
78
+ console.error("> Shared Core auth check passed");
79
+ }
80
+ if (persistedAuthorization) {
81
+ printLoginSuccessBanner();
82
+ }
83
+ else {
84
+ console.log("Authorization 校验通过(来自 TYC_AUTHORIZATION;未写入 ~/.tyc/config.json)");
85
+ }
86
+ }
87
+ else {
88
+ await verifyCoreEndpoint(verifyConfig, verbose);
89
+ console.log("Shared Core endpoint 校验通过");
90
+ }
65
91
  }
66
92
  catch (err) {
67
93
  const msg = err instanceof Error ? err.message : String(err);
68
- console.error(`连通性校验失败:${msg}`);
69
- console.error("配置已保存;确认 --url 可达后重新运行 tyc init,或加 --no-verify 跳过校验。");
94
+ console.error(`配置校验失败:${msg}`);
95
+ console.error("配置已保存;确认 --url 可达且 Authorization 有效后重新运行 tyc init,或加 --no-verify 跳过校验。");
70
96
  process.exit(1);
71
97
  }
72
98
  });
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_MCP_URL, applyOAuthTokenToConfig, defaultCoreURL, loadConfig, resolveConfig, saveConfig, } from "../config.js";
2
2
  import { verifyCoreAuthEndpoint } from "../coreClient.js";
3
- import { buildAuthorizationURL, buildCliCallbackRedirectUri, createPKCE, discoverAuthorizationServer, discoverProtectedResourceMetadata, exchangeAuthorizationCode, knownOAuthDefaultsForMcpURL, openBrowser, parseAuthorizationCallbackCode, randomState, registerDynamicOAuthClient, startCallbackServer, } from "../oauth.js";
3
+ import { buildAuthorizationURL, buildCliCallbackRedirectUri, createPKCE, DEVICE_CODE_GRANT_TYPE, discoverAuthorizationServer, discoverProtectedResourceMetadata, exchangeAuthorizationCode, exchangeDeviceCode, knownOAuthDefaultsForMcpURL, OAuthTokenEndpointError, openBrowser, parseAuthorizationCallbackCode, randomState, registerDynamicOAuthClient, requestDeviceAuthorization, startCallbackServer, } from "../oauth.js";
4
4
  import { clearPendingOAuthLogin, loadPendingOAuthLogin, savePendingOAuthLogin, } from "../oauthPending.js";
5
5
  import { printLoginSuccessBanner } from "../logo.js";
6
6
  function parsePort(raw) {
@@ -45,6 +45,15 @@ async function verifySavedOAuthLogin(verify, verbose) {
45
45
  }
46
46
  printLoginSuccessBanner();
47
47
  }
48
+ function isPendingDeviceLogin(pending) {
49
+ return pending.version === 2;
50
+ }
51
+ function isPendingPKCELogin(pending) {
52
+ return pending.version === 1;
53
+ }
54
+ function sleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }
48
57
  async function completePendingOAuthLogin(pending, callbackToken, verify, verbose) {
49
58
  const code = parseAuthorizationCallbackCode(callbackToken, pending.state);
50
59
  if (verbose) {
@@ -68,6 +77,63 @@ async function completePendingOAuthLogin(pending, callbackToken, verify, verbose
68
77
  clearPendingOAuthLogin(verbose);
69
78
  await verifySavedOAuthLogin(verify, verbose);
70
79
  }
80
+ async function completePendingDeviceLogin(pending, verify, verbose) {
81
+ if (pending.expiresAt <= Date.now()) {
82
+ clearPendingOAuthLogin(verbose);
83
+ throw new Error("OAuth device 授权已过期,请重新运行 tyc login --no-block。");
84
+ }
85
+ let intervalMs = Math.max(1, pending.interval || 5) * 1000;
86
+ const deadline = Date.now() + 30_000;
87
+ for (;;) {
88
+ try {
89
+ if (verbose) {
90
+ console.error("> polling OAuth device token endpoint");
91
+ }
92
+ const token = await exchangeDeviceCode({
93
+ tokenEndpoint: pending.tokenEndpoint,
94
+ clientId: pending.clientId,
95
+ clientSecret: pending.clientSecret,
96
+ deviceCode: pending.deviceCode,
97
+ resource: pending.resource,
98
+ });
99
+ saveOAuthTokenConfig(pending.mcpURL, token, {
100
+ tokenEndpoint: pending.tokenEndpoint,
101
+ clientId: pending.clientId,
102
+ clientSecret: pending.clientSecret,
103
+ resource: pending.resource,
104
+ scope: pending.scope,
105
+ }, verbose);
106
+ clearPendingOAuthLogin(verbose);
107
+ await verifySavedOAuthLogin(verify, verbose);
108
+ return;
109
+ }
110
+ catch (err) {
111
+ if (!(err instanceof OAuthTokenEndpointError)) {
112
+ throw err;
113
+ }
114
+ if (err.error === "authorization_pending" || err.error === "slow_down") {
115
+ if (err.error === "slow_down") {
116
+ intervalMs += 5_000;
117
+ }
118
+ if (Date.now() + intervalMs > deadline) {
119
+ throw new Error("授权尚未完成。请在网页输入 6 位验证码并确认后,再运行 tyc login --resume。");
120
+ }
121
+ await sleep(intervalMs);
122
+ continue;
123
+ }
124
+ if (["expired_token", "access_denied", "invalid_grant"].includes(err.error)) {
125
+ clearPendingOAuthLogin(verbose);
126
+ }
127
+ if (err.error === "expired_token") {
128
+ throw new Error("OAuth device 授权已过期,请重新运行 tyc login --no-block。");
129
+ }
130
+ if (err.error === "access_denied") {
131
+ throw new Error("OAuth device 授权已被拒绝,请重新运行 tyc login --no-block。");
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+ }
71
137
  export function registerLoginCommand(program) {
72
138
  program
73
139
  .command("login")
@@ -81,21 +147,40 @@ export function registerLoginCommand(program) {
81
147
  .option("--resource-metadata-url <url>", "手动指定 MCP protected resource metadata URL")
82
148
  .option("--authorization-endpoint <url>", "手动指定 OAuth authorization endpoint")
83
149
  .option("--token-endpoint <url>", "手动指定 OAuth token endpoint")
150
+ .option("--device-authorization-endpoint <url>", "手动指定 OAuth device authorization endpoint")
84
151
  .option("--redirect-host <host>", "本地回调 host(默认 localhost)")
85
152
  .option("--redirect-port <port>", "本地回调端口(默认 7078)")
86
153
  .option("--callback-path <path>", "本地回调路径(默认 /oauth/callback)")
87
154
  .option("--callback-token <code-or-url>", "继续非阻塞登录:传入授权回调 code 或包含 code 的完整 callback URL")
88
155
  .option("--token <code-or-url>", "同 --callback-token(兼容别名;仍需传授权回调 code)")
156
+ .option("--resume", "继续 RFC 8628 device flow 非阻塞登录")
89
157
  .option("--no-open", "不自动打开浏览器,只打印授权 URL")
90
- .option("--no-block", "配合 --no-open:打印授权 URL 并保存 pending 状态后立即退出")
158
+ .option("--no-block", "打印 Device Flow 授权 URL 和 6 位验证码并保存 pending 状态后立即退出")
91
159
  .option("--no-verify", "保存 token 后跳过 shared core 鉴权校验")
92
160
  .action(async (opts) => {
93
161
  const verbose = !!program.opts().verbose;
94
162
  const callbackToken = pickCallbackToken(opts);
163
+ if (opts.resume) {
164
+ if (callbackToken) {
165
+ throw new Error("--resume 不能与 --callback-token/--token 同时使用。");
166
+ }
167
+ const pending = loadPendingOAuthLogin();
168
+ if (!pending) {
169
+ throw new Error("未找到待完成的 OAuth device 登录状态。请先运行 tyc login --no-block。");
170
+ }
171
+ if (!isPendingDeviceLogin(pending)) {
172
+ throw new Error("当前待完成的是旧版 callback 登录,请使用 tyc login --callback-token <code-or-url>。");
173
+ }
174
+ await completePendingDeviceLogin(pending, opts.verify !== false && pending.verify !== false, verbose);
175
+ return;
176
+ }
95
177
  if (callbackToken) {
96
178
  const pending = loadPendingOAuthLogin();
97
179
  if (!pending) {
98
- throw new Error("未找到待完成的 OAuth 登录状态。请先运行 tyc login --no-open --no-block。");
180
+ throw new Error("未找到待完成的 OAuth 登录状态。请先运行 tyc login --no-block。");
181
+ }
182
+ if (!isPendingPKCELogin(pending)) {
183
+ throw new Error("当前待完成的是 device 登录,请在网页确认后运行 tyc login --resume。");
99
184
  }
100
185
  if (verbose) {
101
186
  console.error(`> loaded pending OAuth login from ~/.tyc/oauth_pending.json (url=${pending.mcpURL})`);
@@ -104,9 +189,6 @@ export function registerLoginCommand(program) {
104
189
  return;
105
190
  }
106
191
  const noBlock = opts.block === false;
107
- if (noBlock && opts.open !== false) {
108
- throw new Error("--no-block 需要与 --no-open 一起使用。");
109
- }
110
192
  const current = loadConfig() || {};
111
193
  const mcpURL = opts.url || process.env.TYC_MCP_ENDPOINT || current.url || DEFAULT_MCP_URL;
112
194
  const knownDefaults = knownOAuthDefaultsForMcpURL(mcpURL);
@@ -158,6 +240,7 @@ export function registerLoginCommand(program) {
158
240
  endpoints = {
159
241
  authorizationEndpoint: opts.authorizationEndpoint,
160
242
  tokenEndpoint: opts.tokenEndpoint,
243
+ deviceAuthorizationEndpoint: opts.deviceAuthorizationEndpoint,
161
244
  issuer,
162
245
  };
163
246
  }
@@ -166,11 +249,16 @@ export function registerLoginCommand(program) {
166
249
  endpoints.authorizationEndpoint =
167
250
  opts.authorizationEndpoint || endpoints.authorizationEndpoint;
168
251
  endpoints.tokenEndpoint = opts.tokenEndpoint || endpoints.tokenEndpoint;
252
+ endpoints.deviceAuthorizationEndpoint =
253
+ opts.deviceAuthorizationEndpoint || endpoints.deviceAuthorizationEndpoint;
169
254
  }
170
255
  resource ||= endpoints.resourceDocumentation || mcpURL;
171
256
  if (verbose) {
172
257
  console.error(`> authorization endpoint: ${endpoints.authorizationEndpoint}`);
173
258
  console.error(`> token endpoint: ${endpoints.tokenEndpoint}`);
259
+ if (endpoints.deviceAuthorizationEndpoint) {
260
+ console.error(`> device authorization endpoint: ${endpoints.deviceAuthorizationEndpoint}`);
261
+ }
174
262
  if (endpoints.registrationEndpoint) {
175
263
  console.error(`> registration endpoint: ${endpoints.registrationEndpoint}`);
176
264
  }
@@ -201,6 +289,10 @@ export function registerLoginCommand(program) {
201
289
  registrationEndpoint: endpoints.registrationEndpoint,
202
290
  clientName: "tyc-cli",
203
291
  redirectUris: [redirectUri],
292
+ grantTypes: noBlock
293
+ ? ["authorization_code", "refresh_token", DEVICE_CODE_GRANT_TYPE]
294
+ : undefined,
295
+ responseTypes: ["code"],
204
296
  tokenEndpointAuthMethod: "none",
205
297
  });
206
298
  }
@@ -214,40 +306,55 @@ export function registerLoginCommand(program) {
214
306
  console.error(`> registered client_id: ${clientId}`);
215
307
  }
216
308
  }
217
- const authorizationURL = buildAuthorizationURL({
218
- authorizationEndpoint: endpoints.authorizationEndpoint,
219
- clientId,
220
- redirectUri,
221
- scope,
222
- state,
223
- codeChallenge: pkce.codeChallenge,
224
- resource,
225
- });
226
- if (verbose) {
227
- console.error(`> OAuth callback: ${redirectUri}`);
228
- }
229
309
  if (noBlock) {
310
+ if (!endpoints.deviceAuthorizationEndpoint) {
311
+ throw new Error("OAuth metadata 未提供 device_authorization_endpoint,请升级授权服务或使用 --device-authorization-endpoint 指定。");
312
+ }
313
+ const device = await requestDeviceAuthorization({
314
+ deviceAuthorizationEndpoint: endpoints.deviceAuthorizationEndpoint,
315
+ clientId,
316
+ clientSecret,
317
+ scope,
318
+ resource,
319
+ });
230
320
  savePendingOAuthLogin({
231
- version: 1,
321
+ version: 2,
322
+ flow: "device",
232
323
  createdAt: Date.now(),
233
324
  mcpURL,
234
325
  tokenEndpoint: endpoints.tokenEndpoint,
235
326
  clientId,
236
327
  clientSecret,
237
- redirectUri,
238
- codeVerifier: pkce.codeVerifier,
328
+ deviceCode: device.device_code,
329
+ userCode: device.user_code,
330
+ verificationUri: device.verification_uri,
331
+ expiresAt: Date.now() + device.expires_in * 1000,
332
+ interval: device.interval || 5,
239
333
  resource,
240
- state,
334
+ scope,
241
335
  verify: opts.verify !== false,
242
336
  });
243
337
  console.error("请在浏览器打开以下 URL 完成登录:");
244
- console.error(authorizationURL);
338
+ console.error(device.verification_uri);
245
339
  console.error("");
246
- console.error("完成授权后,浏览器会打开天眼查 CLI 回调页。请按页面提示复制命令到终端执行。");
247
- console.error("也可以复制完整回调 URL 后运行:");
248
- console.error(`tyc login --callback-token "${redirectUri}?code=...&state=..."`);
340
+ console.error(`验证码:${device.user_code}`);
341
+ console.error("");
342
+ console.error("在网页输入 6 位验证码并确认授权后,运行:");
343
+ console.error("tyc login --resume");
249
344
  return;
250
345
  }
346
+ const authorizationURL = buildAuthorizationURL({
347
+ authorizationEndpoint: endpoints.authorizationEndpoint,
348
+ clientId,
349
+ redirectUri,
350
+ scope,
351
+ state,
352
+ codeChallenge: pkce.codeChallenge,
353
+ resource,
354
+ });
355
+ if (verbose) {
356
+ console.error(`> OAuth callback: ${redirectUri}`);
357
+ }
251
358
  if (opts.open === false) {
252
359
  console.error("请在浏览器打开以下 URL 完成登录:");
253
360
  console.error(authorizationURL);
package/dist/index.js CHANGED
@@ -67,8 +67,9 @@ ${renderCategoryLines()}
67
67
  初始化与登录
68
68
 
69
69
  tyc login OAuth 浏览器登录
70
- tyc login --no-open --no-block 打印 OAuth URL 后立即退出,手动完成
71
- tyc login --callback-token <CODE_OR_CALLBACK_URL> 继续非阻塞 OAuth 登录
70
+ tyc login --no-block 打印 Device Flow URL 和 6 位验证码后立即退出
71
+ tyc login --resume 继续非阻塞 Device Flow 登录
72
+ tyc login --callback-token <CODE_OR_CALLBACK_URL> 继续旧版 callback OAuth 登录
72
73
  tyc login --url <MCP_URL> 登录本地、预发或自托管 MCP
73
74
  tyc init --authorization <KEY> 使用 API Key 兼容路径
74
75
  tyc init --url <MCP_URL> --authorization <KEY> 配置本地或自托管服务
package/dist/oauth.js CHANGED
@@ -1,6 +1,19 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
+ export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5
+ export class OAuthTokenEndpointError extends Error {
6
+ status;
7
+ error;
8
+ errorDescription;
9
+ constructor(status, error, errorDescription) {
10
+ super(errorDescription ? `${error}: ${errorDescription}` : error);
11
+ this.name = "OAuthTokenEndpointError";
12
+ this.status = status;
13
+ this.error = error;
14
+ this.errorDescription = errorDescription;
15
+ }
16
+ }
4
17
  function trimRightSlash(v) {
5
18
  return v.replace(/\/+$/, "");
6
19
  }
@@ -123,10 +136,16 @@ export async function discoverAuthorizationServer(issuer) {
123
136
  return {
124
137
  authorizationEndpoint: metadata.authorization_endpoint,
125
138
  tokenEndpoint: metadata.token_endpoint,
139
+ deviceAuthorizationEndpoint: typeof metadata.device_authorization_endpoint === "string"
140
+ ? metadata.device_authorization_endpoint
141
+ : undefined,
126
142
  registrationEndpoint: typeof metadata.registration_endpoint === "string"
127
143
  ? metadata.registration_endpoint
128
144
  : undefined,
129
145
  issuer,
146
+ grantTypesSupported: Array.isArray(metadata.grant_types_supported)
147
+ ? metadata.grant_types_supported.filter((v) => typeof v === "string")
148
+ : undefined,
130
149
  resourceDocumentation: typeof metadata.resource_documentation === "string"
131
150
  ? metadata.resource_documentation
132
151
  : undefined,
@@ -315,6 +334,54 @@ export async function registerDynamicOAuthClient(opts) {
315
334
  clientSecret: registered.client_secret,
316
335
  };
317
336
  }
337
+ export async function requestDeviceAuthorization(opts) {
338
+ const body = new URLSearchParams();
339
+ body.set("client_id", opts.clientId);
340
+ body.set("scope", opts.scope);
341
+ if (opts.clientSecret)
342
+ body.set("client_secret", opts.clientSecret);
343
+ if (opts.resource)
344
+ body.set("resource", opts.resource);
345
+ const resp = await fetch(opts.deviceAuthorizationEndpoint, {
346
+ method: "POST",
347
+ headers: {
348
+ Accept: "application/json",
349
+ "Content-Type": "application/x-www-form-urlencoded",
350
+ },
351
+ body,
352
+ });
353
+ const text = await resp.text();
354
+ if (resp.status < 200 || resp.status >= 300) {
355
+ throw new Error(`device authorization HTTP ${resp.status}: ${text.slice(0, 500)}`);
356
+ }
357
+ const payload = JSON.parse(text);
358
+ if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
359
+ throw new Error("device authorization response missing required fields");
360
+ }
361
+ return {
362
+ ...payload,
363
+ device_code: payload.device_code,
364
+ user_code: payload.user_code,
365
+ verification_uri: payload.verification_uri,
366
+ expires_in: Number(payload.expires_in) > 0 ? Number(payload.expires_in) : 600,
367
+ interval: Number(payload.interval) > 0 ? Number(payload.interval) : 5,
368
+ };
369
+ }
370
+ function parseOAuthTokenError(status, text) {
371
+ try {
372
+ const payload = JSON.parse(text);
373
+ const error = typeof payload.error === "string" && payload.error
374
+ ? payload.error
375
+ : `token endpoint HTTP ${status}`;
376
+ const description = typeof payload.error_description === "string"
377
+ ? payload.error_description
378
+ : undefined;
379
+ return new OAuthTokenEndpointError(status, error, description);
380
+ }
381
+ catch {
382
+ return new OAuthTokenEndpointError(status, `token endpoint HTTP ${status}`, text.slice(0, 500));
383
+ }
384
+ }
318
385
  export async function exchangeAuthorizationCode(opts) {
319
386
  const body = new URLSearchParams();
320
387
  body.set("grant_type", "authorization_code");
@@ -344,6 +411,33 @@ export async function exchangeAuthorizationCode(opts) {
344
411
  }
345
412
  return token;
346
413
  }
414
+ export async function exchangeDeviceCode(opts) {
415
+ const body = new URLSearchParams();
416
+ body.set("grant_type", DEVICE_CODE_GRANT_TYPE);
417
+ body.set("client_id", opts.clientId);
418
+ body.set("device_code", opts.deviceCode);
419
+ if (opts.clientSecret)
420
+ body.set("client_secret", opts.clientSecret);
421
+ if (opts.resource)
422
+ body.set("resource", opts.resource);
423
+ const resp = await fetch(opts.tokenEndpoint, {
424
+ method: "POST",
425
+ headers: {
426
+ Accept: "application/json",
427
+ "Content-Type": "application/x-www-form-urlencoded",
428
+ },
429
+ body,
430
+ });
431
+ const text = await resp.text();
432
+ if (resp.status < 200 || resp.status >= 300) {
433
+ throw parseOAuthTokenError(resp.status, text);
434
+ }
435
+ const token = JSON.parse(text);
436
+ if (!token.access_token) {
437
+ throw new Error("token endpoint response missing access_token");
438
+ }
439
+ return token;
440
+ }
347
441
  export async function exchangeRefreshToken(opts) {
348
442
  const body = new URLSearchParams();
349
443
  body.set("grant_type", "refresh_token");
@@ -1,7 +1,8 @@
1
1
  // ~/.tyc/oauth_pending.json 读写
2
2
  //
3
- // 非阻塞 OAuth 登录会先保存 PKCE code_verifier、state、client_id 等一次性上下文;
4
- // 用户完成浏览器授权后,再用 tyc login --callback-token <code_or_callback_url> 继续换取 access token。
3
+ // 非阻塞 OAuth 登录会保存一次性上下文:
4
+ // - legacy PKCE 模式保存 code_verifier/state,靠 --callback-token 续接;
5
+ // - RFC 8628 device flow 保存 device_code,靠 --resume 续接。
5
6
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tyc-cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "天眼查 AI CLI — 162 个业务语义聚合工具 · 默认 Shared Core 调用 · 兼容 MCP transport",
5
5
  "main": "dist/index.js",
6
6
  "bin": {