@cnbcool/cnb-api-generate 2.16.11 → 2.17.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.
@@ -88,7 +88,7 @@ async function auth(data: GitCredentialInput): Promise<void> {
88
88
  throw new Error(`unknown host: ${host}`);
89
89
  }
90
90
 
91
- const credential = await fetchCredential(data);
91
+ const credential = await fetchCredential(host);
92
92
 
93
93
  // git-credential 协议:通过 stdout 输出 key=value
94
94
  process.stdout.write(`username=${credential.username}\n`);
@@ -97,9 +97,9 @@ async function auth(data: GitCredentialInput): Promise<void> {
97
97
  console.error('[git-credential]: done');
98
98
  }
99
99
 
100
- async function fetchCredential(_data: GitCredentialInput): Promise<Credential> {
101
- // 使用 resolveToken 获取 access_token 作为 password,username 固定为 cnb
102
- const token = await resolveToken();
100
+ async function fetchCredential(host: string): Promise<Credential> {
101
+ // 按请求 host 取对应域的 access_token 作为 password,username 固定为 cnb
102
+ const token = await resolveToken(host);
103
103
  return {
104
104
  username: 'cnb',
105
105
  password: token,
@@ -1,32 +1,46 @@
1
1
  import { Command } from 'commander';
2
2
  import fs from 'fs';
3
3
  import { getTokenPath } from '../utils/token-path';
4
- import { loadToken } from '../utils/load-token';
4
+ import { readTokenFile } from '../utils/load-token';
5
+ import { removeToken } from '../utils/save-token';
5
6
 
6
7
  export function registerLogoutCommand(program: Command): void {
7
8
  program
8
9
  .command('logout')
9
10
  .description('退出登录,删除本地保存的 access_token')
11
+ .option('--host <string>', '只退出指定平台域名的登录态(如 cnb.cool),不传则退出全部域名的登录态')
10
12
  .option('--debug', '打印调试信息', false)
11
13
  .helpOption('-h, --help', '显示帮助文档')
12
14
  .action(async (opts) => {
13
15
  const tokenPath = getTokenPath();
14
- const token = loadToken();
15
16
 
16
- if (!token) {
17
+ if (opts.host) {
18
+ if (removeToken(opts.host)) {
19
+ console.log(`已成功退出 ${opts.host} 的登录。`);
20
+ } else {
21
+ console.log(`${opts.host} 当前未登录,无需退出。`);
22
+ }
23
+ return;
24
+ }
25
+
26
+ const { auths } = readTokenFile();
27
+ const hosts = Object.keys(auths);
28
+
29
+ if (!hosts.length) {
17
30
  console.log('当前未登录,无需退出。');
18
31
  return;
19
32
  }
20
33
 
21
34
  if (opts.debug) {
22
35
  console.log(`Token 文件路径: ${tokenPath}`);
23
- console.log(`Platform URL : ${token.platform_url}`);
24
- console.log(`Client ID : ${token.client_id}`);
36
+ for (const host of hosts) {
37
+ console.log(`${host}: Platform URL=${auths[host].platform_url}, Client ID=${auths[host].client_id}`);
38
+ }
25
39
  }
26
40
 
27
41
  try {
28
42
  fs.unlinkSync(tokenPath);
29
- console.log('已成功退出登录。');
43
+ console.log(`已成功退出全部登录(${hosts.join('、')})。`);
30
44
  } catch (err: any) {
31
45
  console.error(`退出登录失败: ${err.message}`);
32
46
  process.exit(1);
@@ -13,10 +13,11 @@ export function registerStatusCommand(program: Command): void {
13
13
  program
14
14
  .command('status')
15
15
  .description('检查当前登录状态(token 是否存在、是否过期)')
16
+ .option('--host <string>', '查看指定平台域名的登录态(如 cnb.cool),不传则按当前仓库 git remote、CNB_API_ENDPOINT 的顺序推断,都没有对应登录态时取最近一次保存的')
16
17
  .option('--debug', '打印调试信息', false)
17
18
  .helpOption('-h, --help', '显示帮助文档')
18
19
  .action(async (opts) => {
19
- const result = await resolveTokenSource();
20
+ const result = await resolveTokenSource(opts.host);
20
21
 
21
22
  if (result.source === 'none') {
22
23
  console.log(result.reason ? `⚠️ ${result.reason}` : '⚠️ 未登录,请执行 `cnb login` 进行授权。');
@@ -1,14 +1,84 @@
1
1
  import fs from 'fs';
2
2
  import { getTokenPath } from './token-path';
3
+ import { getGitRemoteHosts, normalizeTokenHost } from './resolve-platform-host';
3
4
  import type { TokenStore } from './types';
4
5
 
5
- /** 读取已保存的 token */
6
- export function loadToken(): TokenStore | null {
6
+ /**
7
+ * ~/.cnb/token 文件格式,参照 docker 的 ~/.docker/config.json,按 host 存放各自的登录态:
8
+ * { "auths": { "<host>": { "access_token": ..., "expires_at": ..., ... } } }
9
+ * host 为平台域名(可带端口),每个 host 一条,互不覆盖。
10
+ */
11
+ export interface TokenFile {
12
+ auths: Record<string, TokenStore>;
13
+ }
14
+
15
+ /** 登录态所属的 host:取 platform_url 的 host,缺失或非法时用 login_host,都不可用时返回 null */
16
+ export function hostOfStore(store: Pick<TokenStore, 'platform_url' | 'login_host'>): string | null {
17
+ for (const url of [store.platform_url, store.login_host]) {
18
+ if (!url) continue;
19
+ try {
20
+ const host = new URL(url).host;
21
+ if (host) return normalizeTokenHost(host);
22
+ } catch {
23
+ // 非法 URL,尝试下一个
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** 读取整个 token 文件;兼容旧版单条 TokenStore 格式 */
30
+ export function readTokenFile(): TokenFile {
7
31
  const tokenPath = getTokenPath();
8
- if (!fs.existsSync(tokenPath)) return null;
32
+ if (!fs.existsSync(tokenPath)) return { auths: {} };
9
33
  try {
10
- return JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
34
+ const data = JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
35
+ if (data?.auths && typeof data.auths === 'object') return data;
36
+ // 旧版格式:文件本身就是一个 TokenStore。按 platform_url / login_host 归到对应 host;
37
+ // 仅当两者都没有可用域名时才归到 cnb.cool(与旧版 refresh 时 platform_url 缺省为 cnb.cool 的行为一致)
38
+ const host = data?.access_token ? hostOfStore(data) ?? 'cnb.cool' : null;
39
+ return host ? { auths: { [host]: data } } : { auths: {} };
40
+ } catch {
41
+ return { auths: {} };
42
+ }
43
+ }
44
+
45
+ let remoteHostsCache: string[] | undefined;
46
+
47
+ /** 当前仓库 git remote 的 host,同一进程只执行一次 git remote -v,避免每次取 token 都 fork 子进程 */
48
+ function remoteHosts(): string[] {
49
+ remoteHostsCache ??= getGitRemoteHosts();
50
+ return remoteHostsCache;
51
+ }
52
+
53
+ /** CNB_API_ENDPOINT(完整 URL)对应的 host key,未设置或非法时返回 null */
54
+ function endpointHost(): string | null {
55
+ const raw = process.env.CNB_API_ENDPOINT?.trim();
56
+ if (!raw) return null;
57
+ try {
58
+ return normalizeTokenHost(new URL(raw).host);
11
59
  } catch {
12
60
  return null;
13
61
  }
14
62
  }
63
+
64
+ /**
65
+ * 读取已保存的 token。
66
+ *
67
+ * 指定 host 时只返回该 host 的登录态。不指定时按以下优先级选择,与 login 默认取 host 的顺序一致:
68
+ * 1. 当前仓库 git remote 的 host
69
+ * 2. CNB_API_ENDPOINT 的 host
70
+ * 3. 以上都没有对应登录态时,回退到最近一次保存的登录态
71
+ */
72
+ export function loadToken(host?: string): TokenStore | null {
73
+ const { auths } = readTokenFile();
74
+ if (host) {
75
+ return auths[normalizeTokenHost(host)] ?? null;
76
+ }
77
+ const candidates = [...remoteHosts(), endpointHost()];
78
+ for (const candidate of candidates) {
79
+ const store = candidate ? auths[normalizeTokenHost(candidate)] : undefined;
80
+ if (store) return store;
81
+ }
82
+ const hosts = Object.keys(auths);
83
+ return hosts.length ? auths[hosts[hosts.length - 1]] : null;
84
+ }
@@ -37,7 +37,8 @@ export async function refreshAccessToken(store: TokenStore): Promise<string> {
37
37
  access_token: tok.access_token,
38
38
  expires_at: nowSec + tok.expires_in,
39
39
  refresh_token: tok.refresh_token || store.refresh_token,
40
- platform_url: store.platform_url,
40
+ // 旧版登录态可能缺少 platform_url,回填本次刷新实际使用的平台地址,保证能按 host 写回
41
+ platform_url: store.platform_url || platformURL,
41
42
  client_id: store.client_id,
42
43
  login_host: store.login_host,
43
44
  };
@@ -52,6 +52,17 @@ function parseUrlHost(url: string): string | null {
52
52
  }
53
53
  }
54
54
 
55
+ /**
56
+ * 平台 host 的规范化规则(唯一来源):去空白、小写、去掉 api. 前缀,保留端口。
57
+ * login 写入登录态与 logout / status / git-credential 读取登录态都以此作为 host key,
58
+ * 修改时两侧会同步生效。
59
+ * @param raw 主机名,如 cnb.cool、API.cnb.cool、example.com:8080
60
+ * @returns 如 cnb.cool、example.com:8080
61
+ */
62
+ export function normalizeTokenHost(raw: string): string {
63
+ return raw.trim().toLowerCase().replace(/^api\./, '');
64
+ }
65
+
55
66
  /**
56
67
  * 将 --host 传入的裸主机名规范化为平台 base URL。
57
68
  * --host 仅接受裸域名(如 cnb.cool、example.com:8080),不兼容 https:// 这类协议头;
@@ -71,10 +82,7 @@ function normalizeHost(raw: string): string | null {
71
82
  if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/i.test(trimmed)) {
72
83
  return null;
73
84
  }
74
- // 主机名大小写不敏感,统一转小写;去掉常见的 api. 前缀(api.cnb.cool -> cnb.cool)
75
- // 使用 host 形式以保留自定义端口,如 example.com:8080
76
- const host = trimmed.toLowerCase().replace(/^api\./, '');
77
- return `https://${host}`;
85
+ return `https://${normalizeTokenHost(trimmed)}`;
78
86
  }
79
87
 
80
88
  /**
@@ -87,10 +95,8 @@ function normalizeEndpoint(raw: string): string | null {
87
95
  try {
88
96
  const u = new URL(raw.trim());
89
97
  if (!u.host) return null;
90
- // 去掉常见的 api. 前缀,统一得到平台域名(api.cnb.cool -> cnb.cool)
91
98
  // 使用 host(而非 hostname)以保留自定义端口,如 example.com:8080
92
- const host = u.host.replace(/^api\./, '');
93
- return `https://${host}`;
99
+ return `https://${normalizeTokenHost(u.host)}`;
94
100
  } catch {
95
101
  return null;
96
102
  }
@@ -17,9 +17,9 @@ export type TokenSourceResult =
17
17
  * 1. WorkBuddy 沙箱环境 → 远程接口获取
18
18
  * 2. CNB_TOKEN_FOR_CODEBUDDY 环境变量
19
19
  * 3. CNB_TOKEN 环境变量
20
- * 4. 文件 token(~/.cnb/token)
20
+ * 4. 文件 token(~/.cnb/token),指定 host 时取该 host 的登录态
21
21
  */
22
- export async function resolveTokenSource(): Promise<TokenSourceResult> {
22
+ export async function resolveTokenSource(host?: string): Promise<TokenSourceResult> {
23
23
  // WorkBuddy 沙箱环境,从远程接口获取 token
24
24
  if (isWorkBuddySandbox()) {
25
25
  const tokenURL = process.env[getTokenEnvKey()];
@@ -73,7 +73,7 @@ export async function resolveTokenSource(): Promise<TokenSourceResult> {
73
73
  }
74
74
 
75
75
  // 文件 token
76
- const store = loadToken();
76
+ const store = loadToken(host);
77
77
  if (!store || !store.access_token) {
78
78
  return { source: 'none' };
79
79
  }
@@ -7,12 +7,14 @@ import { refreshAccessToken } from './refresh-token';
7
7
  * 优先使用环境变量 CNB_TOKEN(不检查过期)。
8
8
  * 否则从 ~/.cnb/token 读取,若已过期则尝试用 refresh_token 刷新;
9
9
  * 刷新失败或无 refresh_token 时终止进程并提示用户重新登录。
10
+ * 指定 host 时只取该 host 的登录态(git-credential 按请求域取凭据)。
10
11
  */
11
- export async function resolveToken(): Promise<string> {
12
- const result = await resolveTokenSource();
12
+ export async function resolveToken(host?: string): Promise<string> {
13
+ const result = await resolveTokenSource(host);
14
+ const loginCmd = host ? `cnb login --host ${host}` : 'cnb login';
13
15
 
14
16
  if (result.source === 'none') {
15
- console.error(result.reason || '未找到有效的登录凭证,请先执行 `cnb login` 进行授权。');
17
+ console.error(result.reason || `未找到有效的登录凭证,请先执行 \`${loginCmd}\` 进行授权。`);
16
18
  process.exit(1);
17
19
  }
18
20
 
@@ -36,11 +38,11 @@ export async function resolveToken(): Promise<string> {
36
38
  return await refreshAccessToken(store);
37
39
  } catch (err: any) {
38
40
  console.error(`Token 已过期,刷新失败: ${err.message}`);
39
- console.error('请重新执行 `cnb login` 进行授权。');
41
+ console.error(`请重新执行 \`${loginCmd}\` 进行授权。`);
40
42
  process.exit(1);
41
43
  }
42
44
  }
43
45
 
44
- console.error('Token 已过期且无 refresh_token,请重新执行 `cnb login` 进行授权。');
46
+ console.error(`Token 已过期且无 refresh_token,请重新执行 \`${loginCmd}\` 进行授权。`);
45
47
  process.exit(1);
46
48
  }
@@ -1,14 +1,38 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { getTokenPath } from './token-path';
4
+ import { hostOfStore, readTokenFile, type TokenFile } from './load-token';
5
+ import { normalizeTokenHost } from './resolve-platform-host';
4
6
  import type { TokenStore } from './types';
5
7
 
6
- /** 持久化 token 到 ~/.cnb/token */
7
- export function saveToken(store: TokenStore): void {
8
+ function writeTokenFile(file: TokenFile): void {
8
9
  const tokenPath = getTokenPath();
9
10
  const dir = path.dirname(tokenPath);
10
11
  if (!fs.existsSync(dir)) {
11
12
  fs.mkdirSync(dir, { recursive: true });
12
13
  }
13
- fs.writeFileSync(tokenPath, JSON.stringify(store, null, 2), { mode: 0o600 });
14
+ fs.writeFileSync(tokenPath, JSON.stringify(file, null, 2), { mode: 0o600 });
15
+ }
16
+
17
+ /** 持久化 token 到 ~/.cnb/token 中对应 host 的条目,其他 host 的条目保持不变 */
18
+ export function saveToken(store: TokenStore): void {
19
+ const host = hostOfStore(store);
20
+ if (!host) {
21
+ throw new Error('无法从登录态推断所属 host(缺少有效的 platform_url)');
22
+ }
23
+ const file = readTokenFile();
24
+ // 先删再加,保证最近保存的 host 排在最后,作为未指定 host 时的兜底登录态
25
+ delete file.auths[host];
26
+ file.auths[host] = store;
27
+ writeTokenFile(file);
28
+ }
29
+
30
+ /** 删除某个 host 的登录态,返回是否存在该条目 */
31
+ export function removeToken(host: string): boolean {
32
+ const file = readTokenFile();
33
+ const key = normalizeTokenHost(host);
34
+ if (!file.auths[key]) return false;
35
+ delete file.auths[key];
36
+ writeTokenFile(file);
37
+ return true;
14
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cnbcool/cnb-api-generate",
3
- "version": "2.16.11",
3
+ "version": "2.17.0",
4
4
  "main": "./built/index.js",
5
5
  "module": "./src/index.ts",
6
6
  "types": "./src/index.ts",