@2api-store/activate 0.3.6 → 0.4.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/README.md CHANGED
@@ -25,12 +25,76 @@ npx @2api-store/activate
25
25
 
26
26
  凭据格式自动识别:`sk-` 前缀走 Key 模式;否则按兑换码处理。
27
27
 
28
+ ## 子命令
29
+
30
+ 除了默认激活流程,还提供 4 个子命令用于后续维护:
31
+
32
+ ```bash
33
+ npx @2api-store/activate <subcommand> [options]
34
+ ```
35
+
36
+ | 子命令 | 作用 |
37
+ |--------|------|
38
+ | `restore` | 从备份恢复:扫描 `~/.codex/`、`~/.claude/`、`~/.gemini/` 下的 `.bak.*` 文件,选择一个恢复(覆盖当前) |
39
+ | `reset` | 恢复官方默认:删除 2api.store 写入的配置文件(`~/.codex/config.toml`、`~/.codex/auth.json`、`~/.claude/settings.json`、`~/.gemini/settings.json`),并尝试从最新备份恢复。**破坏性操作,需确认** |
40
+ | `reconfigure` | 重新配置:输入已有 `sk-` API Key → 重拉最新模板/模型 → 覆盖写入配置(写前备份)。同激活流程但不创建账号 |
41
+ | `refresh-groups` | 查看可用分组:`GET /cli/groups` → 彩色表格展示。只读,不改 key/配置 |
42
+
43
+ 所有子命令支持 `--api-base <url>` 覆盖端点;`restore` / `reset` / `reconfigure` 支持 `--dry-run` 预演。
44
+
45
+ ### restore — 从备份恢复
46
+
47
+ ```bash
48
+ npx @2api-store/activate restore
49
+ npx @2api-store/activate restore --dry-run # 预演
50
+ ```
51
+
52
+ - 扫描三个 CLI 目录下的 `.bak.<时间戳>` 文件,按修改时间倒序列出
53
+ - 选择要恢复的备份,复制回原文件(覆盖当前)
54
+ - 恢复前会自动再产生一份安全备份(防止恢复后想撤销)
55
+ - 原 `.bak.*` 文件保留,可多次恢复
56
+
57
+ ### reset — 恢复官方默认
58
+
59
+ ```bash
60
+ npx @2api-store/activate reset
61
+ ```
62
+
63
+ - 列出 2api.store 写入的配置文件(仅现存的)
64
+ - **删除前再备份一次**(`.bak.reset.<时间戳>`,方便撤销)
65
+ - 删除后尝试从最新常规备份恢复(回到激活前的官方状态)
66
+ - 需要显式确认(默认 `N`,输入 `y` 才执行)
67
+
68
+ ### reconfigure — 重新配置
69
+
70
+ ```bash
71
+ npx @2api-store/activate reconfigure
72
+ npx @2api-store/activate reconfigure --api-base http://127.0.0.1:5566
73
+ ```
74
+
75
+ - 输入现有 `sk-` API Key(不接受兑换码)
76
+ - POST `/cli/activate`(key 模式,不创建账号)→ 重拉最新模板/模型
77
+ - 多选 CLI(默认选主 CLI)→ 选默认模型 → 备份并写入
78
+ - 适合:换了机器、配置丢失、想刷新到最新模板
79
+
80
+ ### refresh-groups — 查看可用分组
81
+
82
+ ```bash
83
+ npx @2api-store/activate refresh-groups
84
+ ```
85
+
86
+ - 彩色表格列出所有可用分组(ID + 名称)
87
+ - 只读,不改 key/配置
88
+ - 如需切组:已有 API Key 走管理后台,或重新激活(兑换码可重选分组)
89
+
28
90
  ## 命令行选项
29
91
 
30
92
  ```
31
- npx @2api-store/activate [options]
93
+ npx @2api-store/activate [options] 默认激活流程
94
+ npx @2api-store/activate <subcommand> [options] 运行子命令
32
95
 
33
- --dry-run, -n 不真写配置,只展示将写入什么
96
+ 选项(默认激活流程 + 所有子命令通用):
97
+ --dry-run, -n 不真写配置,只展示将写入什么(不适用 refresh-groups)
34
98
  --platform <name> 只激活指定平台(openai/anthropic/gemini/grok/antigravity)
35
99
  --api-base <url> 覆盖 API 端点
36
100
  -h, --help 显示帮助
package/activate.mjs CHANGED
@@ -3,9 +3,14 @@
3
3
  process.removeAllListeners('warning')
4
4
  process.env.NODE_NO_WARNINGS = '1'
5
5
  /**
6
- * @2api-store/activate — TokenShop CLI 一键激活(v0.3.0)
6
+ * @2api-store/activate — TokenShop CLI 一键激活(v0.3.0+)
7
7
  *
8
- * 主流程(薄编排,逻辑在各 lib 模块):
8
+ * 入口(argv[2] 分发):
9
+ * - 子命令(restore / reset / reconfigure / refresh-groups)→ 分发到 lib/cmds.mjs
10
+ * - 无子命令 / 选项(--help / --version / --dry-run / --platform / --api-base)
11
+ * → 走默认激活流程 main()
12
+ *
13
+ * 默认激活主流程(薄编排,逻辑在各 lib 模块):
9
14
  * 1. banner + 检测环境 + 列已装/未装 CLI
10
15
  * 2. 输入凭据(sk- → API Key;否则兑换码 + email)
11
16
  * 3. POST /api/v1/cli/activate → 配置包
@@ -18,7 +23,7 @@ process.env.NODE_NO_WARNINGS = '1'
18
23
  */
19
24
 
20
25
  import { detectAll } from './lib/detect.mjs'
21
- import { cliActivate, verifyApiKey } from './lib/api.mjs'
26
+ import { cliActivate, listGroups, verifyApiKey } from './lib/api.mjs'
22
27
  import { renderContent, expandPath, listModels } from './lib/render.mjs'
23
28
  import { backupFile } from './lib/backup.mjs'
24
29
  import { writeFileWithPermission, PermissionDeniedError } from './lib/write.mjs'
@@ -33,6 +38,7 @@ import {
33
38
  import { banner, ok, fail, warn, info, step, guide, plain, chalk } from './lib/ui.mjs'
34
39
  import { getCliMeta, isDefaultSelected } from './lib/platforms.mjs'
35
40
  import { ensureInstalled } from './lib/install.mjs'
41
+ import { SUBCOMMANDS, SUBCOMMAND_NAMES, SUB_HELP } from './lib/cmds.mjs'
36
42
 
37
43
  import { createRequire } from 'node:module'
38
44
  const VERSION = createRequire(import.meta.url)('./package.json').version
@@ -73,22 +79,29 @@ function printHelp() {
73
79
  @2api-store/activate v${VERSION} — TokenShop CLI 一键激活
74
80
 
75
81
  用法:
76
- npx @2api-store/activate [options]
82
+ npx @2api-store/activate [options] 默认激活流程
83
+ npx @2api-store/activate <subcommand> [options] 运行子命令
77
84
 
78
- 选项:
85
+ 选项(默认激活流程):
79
86
  --dry-run, -n 不真写配置/不真装 CLI,只展示将做什么
80
87
  --platform <name> 只激活指定平台(openai/anthropic/gemini/grok/antigravity)
81
88
  --api-base <url> 覆盖 API 端点(默认 $ACTIVATE_API_BASE 或 ${DEFAULT_API_BASE})
82
89
  -h, --help 显示帮助
83
90
  -v, --version 显示版本
84
91
 
92
+ ${SUB_HELP}
93
+
85
94
  环境变量:
86
95
  ACTIVATE_API_BASE 覆盖 API 端点(同 --api-base)
87
96
 
88
97
  示例:
89
- npx @2api-store/activate
90
- npx @2api-store/activate --dry-run
91
- npx @2api-store/activate --platform openai
98
+ npx @2api-store/activate # 默认激活
99
+ npx @2api-store/activate --dry-run # 试运行
100
+ npx @2api-store/activate --platform openai # 只激活 openai
101
+ npx @2api-store/activate restore # 从备份恢复
102
+ npx @2api-store/activate reset # 恢复官方默认
103
+ npx @2api-store/activate reconfigure # 用已有 API Key 重新配置
104
+ npx @2api-store/activate refresh-groups # 查看可用分组
92
105
  ACTIVATE_API_BASE=http://127.0.0.1:5566 npx @2api-store/activate
93
106
 
94
107
  退出码:
@@ -129,6 +142,22 @@ function inferModelPlatforms(models) {
129
142
  // ============================================================
130
143
 
131
144
  async function main() {
145
+ // === 子命令分发:argv[2] 是子命令(restore/reset/reconfigure/refresh-groups)===
146
+ // 注意:必须在 parseArgs 之前判断,因为子命令有自己的 argv 解析(--api-base / --dry-run)
147
+ const firstArg = process.argv[2]
148
+ if (firstArg && !firstArg.startsWith('-') && SUBCOMMAND_NAMES.has(firstArg)) {
149
+ const subRest = process.argv.slice(3)
150
+ const entry = SUBCOMMANDS[firstArg]
151
+ try {
152
+ const code = await entry.handler(subRest)
153
+ return code || 0
154
+ } catch (err) {
155
+ fail(`子命令 ${firstArg} 异常:${err?.message || err}`)
156
+ if (err?.stack) console.error(err.stack)
157
+ return 1
158
+ }
159
+ }
160
+
132
161
  const args = parseArgs(process.argv.slice(2))
133
162
  if (args.help) {
134
163
  printHelp()
@@ -186,21 +215,40 @@ async function main() {
186
215
  }
187
216
  info(`识别为:${kind === 'key' ? 'API Key' : '兑换码'}模式`)
188
217
 
189
- // === 3. 兑换码 → 邮箱 ===
218
+ // === 3. 兑换码 → 邮箱 + 选组 ===
190
219
  let email = ''
220
+ let groupId = null
191
221
  if (kind === 'redeem') {
192
222
  email = (await ask('请输入邮箱(用于兑换码绑定 / 新账号创建)')).trim()
193
223
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
194
224
  fail('邮箱格式不正确')
195
225
  return 1
196
226
  }
227
+
228
+ // 选组:兑换码模式都走选组流程(balance 类型码用 group_id 绑定 + 加余额 + 建 key;
229
+ // api_key 类型码若码已绑组则后端忽略传入的 group_id,但 CLI 选组无害,统一流程)
230
+ step('获取可用分组')
231
+ const groups = await listGroups({ apiBase })
232
+ if (groups.length === 0) {
233
+ fail('未查询到可用分组。请联系管理员开通分组后再激活。')
234
+ return 1
235
+ }
236
+ const groupItems = groups.map((g) => ({ label: `${g.name}`, value: g.id }))
237
+ const groupIdx = await select('选择绑定的分组', groupItems, { defaultIndex: 0 })
238
+ groupId = groups[groupIdx]?.id
239
+ if (!groupId) {
240
+ fail('未选择分组')
241
+ return 1
242
+ }
243
+ ok(`已选分组:${groups[groupIdx].name}(ID ${groupId})`)
244
+ console.log()
197
245
  }
198
246
 
199
247
  // === 4. POST /cli/activate ===
200
248
  step('请求激活服务')
201
249
  let bundle
202
250
  try {
203
- bundle = await cliActivate({ credential, email, platform: args.platform, apiBase })
251
+ bundle = await cliActivate({ credential, email, platform: args.platform, groupId, apiBase })
204
252
  } catch (err) {
205
253
  fail(`激活失败:${err.message}`)
206
254
  if (err.statusCode) fail(` HTTP ${err.statusCode}`)
package/lib/api.mjs CHANGED
@@ -6,10 +6,10 @@ const DEFAULT_TIMEOUT_MS = 30000
6
6
 
7
7
  /**
8
8
  * 发送 POST /api/v1/cli/activate 请求
9
- * @param {{credential: string, email?: string, platform?: string, apiBase: string}} opts
9
+ * @param {{credential: string, email?: string, platform?: string, group_id?: number, apiBase: string}} opts
10
10
  * @returns {Promise<object>} 解析后的 JSON 响应
11
11
  */
12
- export function cliActivate({ credential, email, platform, apiBase }) {
12
+ export function cliActivate({ credential, email, platform, groupId, apiBase }) {
13
13
  return new Promise((resolve, reject) => {
14
14
  let url
15
15
  try {
@@ -24,6 +24,7 @@ export function cliActivate({ credential, email, platform, apiBase }) {
24
24
  credential,
25
25
  ...(email ? { email } : {}),
26
26
  ...(platform ? { platform } : {}),
27
+ ...(groupId ? { group_id: groupId } : {}),
27
28
  }
28
29
  const body = JSON.stringify(payload)
29
30
 
@@ -76,6 +77,69 @@ export function cliActivate({ credential, email, platform, apiBase }) {
76
77
  })
77
78
  }
78
79
 
80
+ /**
81
+ * 获取可用分组列表:GET /api/v1/cli/groups
82
+ * 兑换码模式(balance 类型码无 group_id)CLI 选组用。
83
+ * @param {{apiBase: string}} opts
84
+ * @returns {Promise<Array<{id: number, name: string}>>} 分组列表(精简,仅 id+name)
85
+ */
86
+ export function listGroups({ apiBase }) {
87
+ return new Promise((resolve, reject) => {
88
+ let url
89
+ try {
90
+ url = new URL('/api/v1/cli/groups', apiBase)
91
+ } catch (err) {
92
+ reject(new Error(`无效的 apiBase:${apiBase}(${err.message})`))
93
+ return
94
+ }
95
+
96
+ const lib = url.protocol === 'https:' ? https : http
97
+ const req = lib.request(
98
+ url,
99
+ {
100
+ method: 'GET',
101
+ headers: {
102
+ 'User-Agent': USER_AGENT,
103
+ Accept: 'application/json',
104
+ },
105
+ timeout: DEFAULT_TIMEOUT_MS,
106
+ },
107
+ (res) => {
108
+ const chunks = []
109
+ res.on('data', (c) => chunks.push(c))
110
+ res.on('end', () => {
111
+ const text = Buffer.concat(chunks).toString('utf8')
112
+ let json
113
+ try {
114
+ json = text ? JSON.parse(text) : {}
115
+ } catch {
116
+ // 响应非 JSON,退化为空列表(CLI 兜底)
117
+ resolve([])
118
+ return
119
+ }
120
+ if (res.statusCode >= 200 && res.statusCode < 300) {
121
+ // 后端返回 {groups: [{id,name}]};兼容直接返回数组的形式
122
+ const arr = Array.isArray(json) ? json : json?.groups
123
+ resolve(Array.isArray(arr) ? arr : [])
124
+ } else {
125
+ // 失败退化:返回空列表(不阻塞激活主流程,让用户看到「无组可选」提示)
126
+ resolve([])
127
+ }
128
+ })
129
+ },
130
+ )
131
+
132
+ req.on('timeout', () => {
133
+ req.destroy(new Error(`请求超时(${DEFAULT_TIMEOUT_MS / 1000}s)— ${url.href}`))
134
+ })
135
+ req.on('error', (err) => {
136
+ // 网络错误同样退化为空列表(CLI 兜底)
137
+ resolve([])
138
+ })
139
+ req.end()
140
+ })
141
+ }
142
+
79
143
  /**
80
144
  * 验证 API Key:GET {baseUrl}/v1/models(Bearer api_key),期望 200
81
145
  * @param {{apiKey: string, baseUrl: string}} opts
package/lib/backup.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs'
2
+ import path from 'node:path'
2
3
 
3
4
  /**
4
5
  * 备份目标文件到同目录 .bak.<ISO时间戳>(非覆盖,保留多次)。
@@ -8,14 +9,14 @@ import fs from 'node:fs'
8
9
  * - 文件存在且备份成功 → 返回备份路径
9
10
  * - 文件存在但备份失败 → 抛错(调用方应中止写入,避免无备份覆盖)
10
11
  *
11
- * @param {string} path 目标文件绝对路径
12
+ * @param {string} targetPath 目标文件绝对路径
12
13
  * @returns {string|null} 备份路径,或 null(文件不存在)
13
14
  * @throws {Error} 文件存在但 copyFileSync 失败
14
15
  */
15
- export function backupFile(path) {
16
+ export function backupFile(targetPath) {
16
17
  let exists = false
17
18
  try {
18
- exists = fs.existsSync(path)
19
+ exists = fs.existsSync(targetPath)
19
20
  } catch {
20
21
  // 无法检查存在性(权限等)— 视为不存在,best effort
21
22
  return null
@@ -24,9 +25,85 @@ export function backupFile(path) {
24
25
 
25
26
  // ISO 时间戳,文件名安全化(: 和 . 替换为 -)
26
27
  const ts = new Date().toISOString().replace(/[:.]/g, '-')
27
- const backupPath = `${path}.bak.${ts}`
28
+ const backupPath = `${targetPath}.bak.${ts}`
28
29
 
29
30
  // copyFileSync 失败会抛出 — 调用方知道文件存在且备份失败
30
- fs.copyFileSync(path, backupPath)
31
+ fs.copyFileSync(targetPath, backupPath)
31
32
  return backupPath
32
33
  }
34
+
35
+ /**
36
+ * 扫描多个目录下所有 .bak.<timestamp> 备份文件,按修改时间倒序排列。
37
+ *
38
+ * 文件名格式约定:<original>.bak.<ISO时间戳>(backupFile 写入)。
39
+ *
40
+ * @param {string[]} dirs 要扫描的目录绝对路径列表
41
+ * @returns {Array<{bakPath: string, origPath: string, mtime: number, dir: string, file: string, origName: string, size: number}>}
42
+ * 按修改时间倒序(最新在前)
43
+ */
44
+ export function listBackups(dirs) {
45
+ const backups = []
46
+ for (const dir of dirs) {
47
+ if (!dir) continue
48
+ let entries
49
+ try {
50
+ entries = fs.readdirSync(dir, { withFileTypes: true })
51
+ } catch {
52
+ // 目录不存在或权限不足 — 跳过
53
+ continue
54
+ }
55
+ for (const entry of entries) {
56
+ if (!entry.isFile()) continue
57
+ // 匹配 <filename>.bak.<timestamp>(timestamp 含 T Z - 数字)
58
+ const match = entry.name.match(/^(.+)\.bak\.[\dTZ:-]+$/)
59
+ if (!match) continue
60
+ const origName = match[1]
61
+ const bakPath = path.join(dir, entry.name)
62
+ const origPath = path.join(dir, origName)
63
+ try {
64
+ const stat = fs.statSync(bakPath)
65
+ backups.push({
66
+ bakPath,
67
+ origPath,
68
+ mtime: stat.mtimeMs,
69
+ dir,
70
+ file: entry.name,
71
+ origName,
72
+ size: stat.size,
73
+ })
74
+ } catch {
75
+ // stat 失败 — 跳过
76
+ }
77
+ }
78
+ }
79
+ // 修改时间倒序(最新在前)
80
+ backups.sort((a, b) => b.mtime - a.mtime)
81
+ return backups
82
+ }
83
+
84
+ /**
85
+ * 从备份恢复(复制回原文件,覆盖当前)。
86
+ *
87
+ * 行为:
88
+ * - 备份文件不存在 → 抛错(调用方应预先校验)
89
+ * - 目标父目录不存在 → 自动 mkdir
90
+ * - 恢复后保留备份文件(不删,多次恢复可用)
91
+ *
92
+ * @param {string} bakPath 备份文件绝对路径
93
+ * @param {string} origPath 原文件目标绝对路径
94
+ * @returns {string} origPath(恢复后的路径)
95
+ * @throws {Error} 备份文件不存在或复制失败
96
+ */
97
+ export function restoreBackup(bakPath, origPath) {
98
+ if (!fs.existsSync(bakPath)) {
99
+ throw new Error(`备份文件不存在:${bakPath}`)
100
+ }
101
+ const dir = path.dirname(origPath)
102
+ try {
103
+ fs.mkdirSync(dir, { recursive: true })
104
+ } catch {
105
+ // 父目录已存在或创建失败忽略(copyFileSync 会再报)
106
+ }
107
+ fs.copyFileSync(bakPath, origPath)
108
+ return origPath
109
+ }
package/lib/cmds.mjs ADDED
@@ -0,0 +1,702 @@
1
+ /**
2
+ * 子命令实现:restore / reset / reconfigure / refresh-groups
3
+ *
4
+ * 设计原则:
5
+ * - 每个子命令是独立 async 函数,接收 argv 数组(不含子命令名本身)
6
+ * - 返回退出码(0 成功 / 1 输入错误 / 2 权限 / 3 写入失败)
7
+ * - 自包含:解析自己的 argv + 走完整流程
8
+ * - 操作配置文件前都备份(reset 删前备份;reconfigure 写前备份)
9
+ * - 不复用 activate.mjs 的 main()(避免耦合,main 包含 redeem/CLI 多选/安装等复杂流程)
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+
16
+ import { backupFile, listBackups, restoreBackup } from './backup.mjs'
17
+ import { cliActivate, listGroups } from './api.mjs'
18
+ import { writeFileWithPermission, PermissionDeniedError } from './write.mjs'
19
+ import { renderContent, expandPath, listModels } from './render.mjs'
20
+ import { getCliMeta, isDefaultSelected } from './platforms.mjs'
21
+ import { detectAll } from './detect.mjs'
22
+ import {
23
+ ask,
24
+ askSecret,
25
+ multiSelect,
26
+ select,
27
+ confirm,
28
+ closePrompt,
29
+ } from './prompt.mjs'
30
+ import { banner, ok, fail, warn, info, step, plain, guide, chalk } from './ui.mjs'
31
+
32
+ // ============================================================
33
+ // 常量
34
+ // ============================================================
35
+
36
+ const DEFAULT_API_BASE = 'https://2api.store'
37
+
38
+ /**
39
+ * 2api.store 写入的配置文件清单(reset 用)
40
+ * 注意:~/.claude/settings.json 整文件删除(如用户有自定义设置,先备份后删,
41
+ * 若存在 .bak.* 则恢复到删除前的官方状态)
42
+ */
43
+ const MANAGED_CONFIG_FILES_UNIX = [
44
+ '~/.codex/config.toml',
45
+ '~/.codex/auth.json',
46
+ '~/.claude/settings.json',
47
+ '~/.gemini/settings.json',
48
+ ]
49
+
50
+ const MANAGED_CONFIG_FILES_WIN = [
51
+ '%userprofile%\\.codex\\config.toml',
52
+ '%userprofile%\\.codex\\auth.json',
53
+ '%userprofile%\\.claude\\settings.json',
54
+ '%userprofile%\\.gemini\\settings.json',
55
+ ]
56
+
57
+ /** 扫描备份文件的目录 */
58
+ const BACKUP_SCAN_DIRS_UNIX = ['~/.codex', '~/.claude', '~/.gemini']
59
+ const BACKUP_SCAN_DIRS_WIN = [
60
+ '%userprofile%\\.codex',
61
+ '%userprofile%\\.claude',
62
+ '%userprofile%\\.gemini',
63
+ ]
64
+
65
+ // ============================================================
66
+ // 工具:解析子命令自己的 argv(--api-base / --dry-run / --help)
67
+ // ============================================================
68
+
69
+ function parseSubArgs(argv) {
70
+ const args = { dryRun: false, _: [] }
71
+ for (let i = 0; i < argv.length; i++) {
72
+ const a = argv[i]
73
+ if (a === '--dry-run' || a === '-n') {
74
+ args.dryRun = true
75
+ } else if (a === '--api-base') {
76
+ args.apiBase = argv[++i]
77
+ } else if (a === '--help' || a === '-h') {
78
+ args.help = true
79
+ } else if (a.startsWith('--') && a.includes('=')) {
80
+ const [k, v] = a.slice(2).split(/=(.*)/, 2)
81
+ args[k] = v
82
+ } else if (a.startsWith('--')) {
83
+ args[a.replace(/^--/, '')] = argv[++i]
84
+ } else {
85
+ args._.push(a)
86
+ }
87
+ }
88
+ return args
89
+ }
90
+
91
+ function resolveHome(p, isWindows) {
92
+ return expandPath(p, { isWindows })
93
+ }
94
+
95
+ function getManagedFiles(isWindows) {
96
+ return (isWindows ? MANAGED_CONFIG_FILES_WIN : MANAGED_CONFIG_FILES_UNIX).map((p) =>
97
+ resolveHome(p, isWindows),
98
+ )
99
+ }
100
+
101
+ function getBackupScanDirs(isWindows) {
102
+ return (isWindows ? BACKUP_SCAN_DIRS_WIN : BACKUP_SCAN_DIRS_UNIX).map((p) =>
103
+ resolveHome(p, isWindows),
104
+ )
105
+ }
106
+
107
+ /** 格式化时间为本地可读格式 */
108
+ function fmtTime(ms) {
109
+ try {
110
+ const d = new Date(ms)
111
+ const pad = (n) => String(n).padStart(2, '0')
112
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
113
+ } catch {
114
+ return new Date(ms).toISOString()
115
+ }
116
+ }
117
+
118
+ /** 格式化文件大小 */
119
+ function fmtSize(bytes) {
120
+ if (bytes < 1024) return `${bytes}B`
121
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
122
+ return `${(bytes / 1024 / 1024).toFixed(2)}MB`
123
+ }
124
+
125
+ const SUB_HELP = `
126
+ 子命令:
127
+ restore 从备份恢复(扫描 ~/.codex / ~/.claude / ~/.gemini 下的 .bak.* 文件)
128
+ reset 恢复官方默认(删 2api.store 写入的配置,并从最新备份恢复)
129
+ reconfigure 重新配置(输入已有 sk- API Key → 重拉模板/模型 → 覆盖写入)
130
+ refresh-groups 刷新分组列表(只展示,不改 key/配置)
131
+
132
+ 所有子命令都支持 --api-base <url> 覆盖端点,restore/reset/reconfigure 支持 --dry-run。
133
+ `.trim()
134
+
135
+ // ============================================================
136
+ // restore:从备份恢复
137
+ // ============================================================
138
+
139
+ async function cmdRestore(argv) {
140
+ const args = parseSubArgs(argv)
141
+ if (args.help) {
142
+ console.log(`npx @2api-store/activate restore [options]\n\n${SUB_HELP}`)
143
+ return 0
144
+ }
145
+
146
+ banner()
147
+ const env = detectAll()
148
+ if (!env.node.supported) {
149
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
150
+ return 1
151
+ }
152
+
153
+ step('扫描备份文件')
154
+ const dirs = getBackupScanDirs(env.os.isWindows)
155
+ if (args.dryRun) warn('【试运行模式 --dry-run】不会真实恢复文件')
156
+ info(`扫描目录:${dirs.join(', ')}`)
157
+
158
+ const backups = listBackups(dirs)
159
+ if (backups.length === 0) {
160
+ info('未找到任何备份文件(.bak.*)')
161
+ info('提示:只有运行过 npx @2api-store/activate 后才会产生备份文件')
162
+ return 0
163
+ }
164
+
165
+ ok(`找到 ${backups.length} 个备份文件(按时间倒序)`)
166
+ console.log()
167
+
168
+ // 构建选项
169
+ const items = backups.map((b) => {
170
+ const rel = path.relative(path.dirname(b.bakPath), b.bakPath)
171
+ const ts = fmtTime(b.mtime)
172
+ const sz = fmtSize(b.size)
173
+ const orig = chalk.dim(`→ ${resolveHome('~/' + path.relative(process.env.HOME || '', b.origPath), env.os.isWindows)}`)
174
+ return {
175
+ label: `${chalk.cyan(rel)} ${chalk.dim(ts)} ${chalk.yellow(sz)} ${orig}`,
176
+ backup: b,
177
+ }
178
+ })
179
+
180
+ step('选择要恢复的备份')
181
+ const idx = await select('选择备份(回车=最新)', items, { defaultIndex: 0 })
182
+ if (idx < 0 || idx >= items.length) {
183
+ fail('未选择备份')
184
+ return 1
185
+ }
186
+ const chosen = items[idx].backup
187
+ console.log()
188
+ info(`将恢复:${chosen.bakPath}`)
189
+ info(` → ${chosen.origPath}`)
190
+
191
+ if (args.dryRun) {
192
+ warn('[试运行] 不真实恢复')
193
+ return 0
194
+ }
195
+
196
+ // 恢复前再备份当前(如存在)— 双向保险:恢复后想撤销还能找回
197
+ if (fs.existsSync(chosen.origPath)) {
198
+ try {
199
+ const safetyBak = backupFile(chosen.origPath)
200
+ if (safetyBak) info(`安全备份当前文件:${safetyBak}(恢复前自动产生,方便撤销)`)
201
+ } catch (err) {
202
+ warn(`安全备份失败(继续恢复):${err.message}`)
203
+ }
204
+ }
205
+
206
+ try {
207
+ restoreBackup(chosen.bakPath, chosen.origPath)
208
+ ok(`已恢复:${chosen.origPath}`)
209
+ info('(原备份文件保留,可多次恢复)')
210
+ return 0
211
+ } catch (err) {
212
+ fail(`恢复失败:${err.message}`)
213
+ return 3
214
+ }
215
+ }
216
+
217
+ // ============================================================
218
+ // reset:恢复官方默认
219
+ // ============================================================
220
+
221
+ async function cmdReset(argv) {
222
+ const args = parseSubArgs(argv)
223
+ if (args.help) {
224
+ console.log(`npx @2api-store/activate reset [options]\n\n${SUB_HELP}`)
225
+ return 0
226
+ }
227
+
228
+ banner()
229
+ const env = detectAll()
230
+ if (!env.node.supported) {
231
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
232
+ return 1
233
+ }
234
+
235
+ step('定位 2api.store 写入的配置')
236
+ const managedFiles = getManagedFiles(env.os.isWindows)
237
+ const existing = managedFiles.filter((p) => {
238
+ try {
239
+ return fs.existsSync(p)
240
+ } catch {
241
+ return false
242
+ }
243
+ })
244
+
245
+ if (existing.length === 0) {
246
+ warn('未发现 2api.store 写入的配置文件(可能已 reset 或从未激活过)')
247
+ info('如需从备份恢复,请运行:npx @2api-store/activate restore')
248
+ return 0
249
+ }
250
+
251
+ info('将处理以下文件:')
252
+ existing.forEach((p) => plain(` - ${chalk.yellow(p)}`))
253
+ console.log()
254
+
255
+ // 扫描最新备份
256
+ const dirs = getBackupScanDirs(env.os.isWindows)
257
+ const allBackups = listBackups(dirs)
258
+ const hasBackups = allBackups.length > 0
259
+ if (hasBackups) {
260
+ ok(`发现 ${allBackups.length} 个备份文件(reset 后将尝试恢复每个配置的最新备份)`)
261
+ } else {
262
+ warn('未找到任何备份文件(reset 后这些文件将直接删除,无备份可恢复)')
263
+ }
264
+
265
+ console.log()
266
+ step('确认操作')
267
+ warn('⚠️ 这是破坏性操作:将删除上述配置文件并尝试从备份恢复。')
268
+ warn(' 删除前会再备份一次(.bak.reset.<时间戳>),可手动恢复。')
269
+ const sure = await confirm('确认 reset 吗?', { defaultValue: false })
270
+ if (!sure) {
271
+ plain('已取消')
272
+ return 0
273
+ }
274
+ console.log()
275
+
276
+ if (args.dryRun) {
277
+ warn('【试运行模式 --dry-run】不会真实删除/恢复文件')
278
+ info('将执行:')
279
+ existing.forEach((p) => plain(` - 删除 ${p}`))
280
+ if (hasBackups) {
281
+ info('并从最新备份恢复:')
282
+ allBackups.slice(0, 5).forEach((b) => plain(` - ${b.bakPath} → ${b.origPath}`))
283
+ if (allBackups.length > 5) plain(` ...(共 ${allBackups.length} 个备份)`)
284
+ }
285
+ return 0
286
+ }
287
+
288
+ // 1) 删除前再备份一次(.bak.reset.<ts>),保险可撤销
289
+ step('安全备份当前文件')
290
+ const safetyBackups = []
291
+ for (const p of existing) {
292
+ try {
293
+ // 用自定义后缀,避免与 listBackups 的常规 .bak.<ts> 混淆
294
+ const ts = new Date().toISOString().replace(/[:.]/g, '-')
295
+ const safetyPath = `${p}.bak.reset.${ts}`
296
+ fs.copyFileSync(p, safetyPath)
297
+ safetyBackups.push(safetyPath)
298
+ plain(` ✓ ${p} → ${safetyPath}`)
299
+ } catch (err) {
300
+ fail(`安全备份失败 ${p}:${err.message}(跳过删除此文件避免误伤)`)
301
+ }
302
+ }
303
+ if (safetyBackups.length > 0) {
304
+ ok(`已安全备份 ${safetyBackups.length} 个文件`)
305
+ }
306
+ console.log()
307
+
308
+ // 2) 删除 managed 文件
309
+ step('删除 2api.store 配置')
310
+ const deleted = []
311
+ for (const p of existing) {
312
+ try {
313
+ fs.unlinkSync(p)
314
+ ok(`已删除:${p}`)
315
+ deleted.push(p)
316
+ } catch (err) {
317
+ if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'EROFS') {
318
+ fail(`权限不足,无法删除 ${p}(请用 sudo / 管理员 PowerShell 重跑)`)
319
+ } else {
320
+ fail(`删除失败 ${p}:${err.message}`)
321
+ }
322
+ }
323
+ }
324
+ console.log()
325
+
326
+ // 3) 从最新备份恢复(按 origPath 分组,取每个 origPath 的最新备份)
327
+ if (hasBackups) {
328
+ step('从最新备份恢复')
329
+ // 按 origPath 分组,取每组最新的
330
+ const byOrig = new Map()
331
+ for (const b of allBackups) {
332
+ const cur = byOrig.get(b.origPath)
333
+ if (!cur || b.mtime > cur.mtime) byOrig.set(b.origPath, b)
334
+ }
335
+ let restoredCount = 0
336
+ for (const [origPath, b] of byOrig) {
337
+ try {
338
+ restoreBackup(b.bakPath, origPath)
339
+ ok(`已恢复:${b.bakPath} → ${origPath}`)
340
+ restoredCount++
341
+ } catch (err) {
342
+ warn(`恢复失败 ${origPath}:${err.message}(文件已删除,未恢复)`)
343
+ }
344
+ }
345
+ if (restoredCount > 0) info(`从备份恢复 ${restoredCount} 个文件`)
346
+ console.log()
347
+ }
348
+
349
+ // 报告
350
+ step('reset 报告')
351
+ ok(`已删除 ${deleted.length} 个 2api.store 配置文件`)
352
+ if (safetyBackups.length > 0) {
353
+ info('安全备份位置(如需撤销 reset,手动恢复这些文件):')
354
+ safetyBackups.forEach((b) => plain(` - ${b}`))
355
+ }
356
+ info('下一步:如需重新激活,运行 npx @2api-store/activate')
357
+
358
+ return deleted.length > 0 ? 0 : 1
359
+ }
360
+
361
+ // ============================================================
362
+ // reconfigure:重新配置
363
+ // ============================================================
364
+
365
+ async function cmdReconfigure(argv) {
366
+ const args = parseSubArgs(argv)
367
+ if (args.help) {
368
+ console.log(`npx @2api-store/activate reconfigure [options]\n\n${SUB_HELP}`)
369
+ return 0
370
+ }
371
+
372
+ banner()
373
+ const env = detectAll()
374
+ const osLabel = env.os.isMac
375
+ ? 'macOS'
376
+ : env.os.isLinux
377
+ ? 'Linux'
378
+ : env.os.isWindows
379
+ ? 'Windows'
380
+ : env.os.platform
381
+ info(`环境:${osLabel} · node v${env.node.version}`)
382
+ if (!env.node.supported) {
383
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
384
+ return 1
385
+ }
386
+
387
+ const apiBase = args.apiBase || process.env.ACTIVATE_API_BASE || DEFAULT_API_BASE
388
+ info(`API 端点:${apiBase}`)
389
+ if (args.dryRun) warn('【试运行模式 --dry-run】不会真写配置')
390
+ console.log()
391
+
392
+ // 输入 API Key
393
+ const apiKey = (await askSecret('请输入现有的 API Key (sk-...)')).trim()
394
+ if (!apiKey) {
395
+ fail('未输入 API Key')
396
+ return 1
397
+ }
398
+ if (!apiKey.startsWith('sk-')) {
399
+ fail('reconfigure 仅支持 API Key 模式(sk- 前缀)。如需使用兑换码,请运行默认激活流程:npx @2api-store/activate')
400
+ return 1
401
+ }
402
+ info('识别为:API Key 模式(不创建账号,仅重拉模板写入配置)')
403
+ console.log()
404
+
405
+ // 询问是否限制平台(可选)
406
+ const platformAnswer = (await ask('限制平台?(留空=全部,或输入 openai/anthropic/gemini/grok/antigravity)')).trim()
407
+ const platform = platformAnswer || null
408
+ if (platform) info(`平台过滤:${platform}`)
409
+ console.log()
410
+
411
+ // POST /cli/activate
412
+ step('请求激活服务(key 模式重拉模板)')
413
+ let bundle
414
+ try {
415
+ bundle = await cliActivate({ credential: apiKey, platform, apiBase })
416
+ } catch (err) {
417
+ fail(`请求失败:${err.message}`)
418
+ if (err.statusCode) fail(` HTTP ${err.statusCode}`)
419
+ return 1
420
+ }
421
+ if (bundle.mode && bundle.mode !== 'key') {
422
+ warn(`服务端返回模式:${bundle.mode}(预期 key 模式,但继续)`)
423
+ }
424
+ ok('已获取最新配置模板')
425
+ if (bundle.base_url) info(`base_url:${bundle.base_url}`)
426
+ console.log()
427
+
428
+ // 过滤配置
429
+ const allowedPlatforms = new Set()
430
+ for (const m of bundle.models || []) {
431
+ const id = (typeof m === 'string' ? m : m?.id || '').toLowerCase()
432
+ if (/^(gpt|o1|o3|codex|glm|minimax|deepseek)/.test(id)) allowedPlatforms.add('openai')
433
+ else if (id.startsWith('claude')) allowedPlatforms.add('anthropic')
434
+ else if (id.startsWith('gemini')) allowedPlatforms.add('gemini')
435
+ else if (id.startsWith('grok')) allowedPlatforms.add('grok')
436
+ }
437
+ if (platform) allowedPlatforms.add(platform)
438
+
439
+ const osConfigs = (bundle.configs || []).filter((c) => {
440
+ if (!c) return false
441
+ if (allowedPlatforms.size > 0 && !allowedPlatforms.has(c.platform)) return false
442
+ if (platform && c.platform !== platform) return false
443
+ if (env.os.isWindows) {
444
+ return c.os_type === 'windows' || c.os_type === 'all'
445
+ }
446
+ return c.os_type === 'unix' || c.os_type === 'all'
447
+ })
448
+
449
+ if (osConfigs.length === 0) {
450
+ fail('服务端未返回适配当前系统的配置模板')
451
+ return 1
452
+ }
453
+
454
+ // dedupe by platform + client_type
455
+ const seen = new Set()
456
+ const cliItems = []
457
+ for (const c of osConfigs) {
458
+ const key = `${c.platform}/${c.client_type}`
459
+ if (seen.has(key)) continue
460
+ seen.add(key)
461
+ const meta = getCliMeta(c.client_type)
462
+ const displayName = c.display_name || c.displayName || meta?.name || `${c.platform} ${c.client_type}`
463
+ const installed = env.installed[c.client_type]?.path || (meta && env.installed[meta.detectBin]?.path)
464
+ const primaryMark = isDefaultSelected(c.client_type) ? '' : '(独立 CLI)'
465
+ const nameColored = installed ? chalk.green(displayName) : chalk.gray(`${displayName} (未装)`)
466
+ cliItems.push({
467
+ label: `${nameColored} ${chalk.dim(`[${c.platform}/${c.client_type}]`)}${primaryMark}`,
468
+ config: c,
469
+ clientType: c.client_type,
470
+ meta,
471
+ alreadyInstalled: !!installed,
472
+ })
473
+ }
474
+
475
+ const defaultSelected = cliItems
476
+ .map((item, i) => ({ item, i }))
477
+ .filter(({ item }) => isDefaultSelected(item.clientType))
478
+ .map(({ i }) => i)
479
+
480
+ step('选择要重配的 CLI')
481
+ const selectedIdxes = await multiSelect(
482
+ '选择 CLI(绿色=已装,灰色=未装;默认选主 CLI)',
483
+ cliItems,
484
+ { defaultSelected },
485
+ )
486
+ if (selectedIdxes.length === 0) {
487
+ plain('未选择任何 CLI,退出。')
488
+ return 0
489
+ }
490
+ const selectedItems = selectedIdxes.map((i) => cliItems[i])
491
+ console.log()
492
+
493
+ // 选默认模型
494
+ const modelItems = listModels(bundle.models || [])
495
+ let defaultModelId = ''
496
+ if (modelItems.length > 0) {
497
+ step('选择默认模型')
498
+ const TOP_N = 10
499
+ const topItems = modelItems.slice(0, TOP_N)
500
+ const hasMore = modelItems.length > TOP_N
501
+ const selectItems = [
502
+ ...topItems.map((m) => ({ label: `${m.label} (${m.id})` })),
503
+ ...(hasMore ? [{ label: chalk.dim('其他(手动输入)') }] : []),
504
+ ]
505
+ const idx = await select('选择默认模型', selectItems, { defaultIndex: 0 })
506
+ if (hasMore && idx === topItems.length) {
507
+ defaultModelId = (await ask('输入模型 ID')).trim() || topItems[0].id
508
+ } else {
509
+ defaultModelId = topItems[idx].id
510
+ }
511
+ ok(`已选默认模型:${defaultModelId}`)
512
+ } else {
513
+ warn('服务端未返回模型列表,{{defaultModel}} 将留空')
514
+ }
515
+ console.log()
516
+
517
+ // 渲染
518
+ const baseUrl = bundle.base_url || ''
519
+ const baseUrlBeta = bundle.base_url_beta || baseUrl
520
+ const renderValues = {
521
+ apiKey: bundle.api_key || apiKey,
522
+ baseUrl,
523
+ apiBase: baseUrl,
524
+ apiBaseBeta: baseUrlBeta,
525
+ antigravityApiBase: `${baseUrl}/antigravity`,
526
+ antigravityApiBaseBeta: `${baseUrlBeta}/antigravity`,
527
+ defaultModel: defaultModelId,
528
+ }
529
+
530
+ // 备份 + 写入
531
+ step('备份并写入配置')
532
+ const report = []
533
+ let firstPermissionError = null
534
+
535
+ for (const item of selectedItems) {
536
+ const displayName =
537
+ item.config.display_name || item.config.displayName || item.meta?.name || item.clientType
538
+ plain(`── ${displayName} ──`)
539
+ const files = item.config.files || []
540
+ for (const f of files) {
541
+ const rawPath = f.path || ''
542
+ const isShellOnly = rawPath === 'Terminal' || rawPath === 'PowerShell'
543
+ const targetPath = isShellOnly
544
+ ? rawPath
545
+ : expandPath(rawPath, { isWindows: env.os.isWindows })
546
+ const rendered = renderContent(f.content || '', renderValues)
547
+
548
+ if (isShellOnly) {
549
+ plain(' [shell 命令,请手动执行]')
550
+ plain(rendered.split('\n').map((l) => ' ' + l).join('\n'))
551
+ report.push({ cli: displayName, path: rawPath, action: 'shell-only' })
552
+ continue
553
+ }
554
+
555
+ plain(` 目标:${targetPath}`)
556
+
557
+ if (args.dryRun) {
558
+ warn(' [试运行] 不真实写入')
559
+ report.push({ cli: displayName, path: targetPath, action: 'dry-run' })
560
+ console.log()
561
+ continue
562
+ }
563
+
564
+ let backupPath = null
565
+ try {
566
+ backupPath = backupFile(targetPath)
567
+ if (backupPath) plain(` 备份:${backupPath}`)
568
+ } catch (err) {
569
+ fail(`备份失败:${err.message}(跳过此文件避免无备份覆盖)`)
570
+ report.push({ cli: displayName, path: targetPath, action: 'backup-failed', error: err.message })
571
+ continue
572
+ }
573
+
574
+ try {
575
+ writeFileWithPermission(targetPath, rendered, { isWindows: env.os.isWindows })
576
+ ok('已写入')
577
+ report.push({ cli: displayName, path: targetPath, action: 'written', backup: backupPath })
578
+ } catch (err) {
579
+ if (err instanceof PermissionDeniedError) {
580
+ if (!firstPermissionError) firstPermissionError = err
581
+ fail(err.message)
582
+ report.push({ cli: displayName, path: targetPath, action: 'permission-denied', error: err.message })
583
+ } else {
584
+ fail(`写入失败:${err.message}`)
585
+ report.push({ cli: displayName, path: targetPath, action: 'failed', error: err.message })
586
+ }
587
+ }
588
+ }
589
+ console.log()
590
+ }
591
+
592
+ // 报告
593
+ step('重配报告')
594
+ const written = report.filter((r) => r.action === 'written')
595
+ const failed = report.filter(
596
+ (r) => r.action === 'failed' || r.action === 'permission-denied' || r.action === 'backup-failed',
597
+ )
598
+ if (args.dryRun) {
599
+ plain(`[试运行] 将写入 ${report.filter((r) => r.action === 'dry-run').length} 个文件`)
600
+ } else {
601
+ ok(`已写入 ${written.length} 个文件`)
602
+ }
603
+ if (failed.length > 0) {
604
+ warn(`失败 ${failed.length} 项:`)
605
+ failed.forEach((f) => warn(` - ${f.cli} ${f.path}`))
606
+ }
607
+ const backups = report.filter((r) => r.backup).map((r) => r.backup)
608
+ if (backups.length > 0) {
609
+ info('备份位置:')
610
+ backups.forEach((b) => plain(` - ${b}`))
611
+ }
612
+
613
+ if (firstPermissionError) {
614
+ console.log()
615
+ fail('部分文件权限不足,请按提示用 sudo / 管理员 PowerShell 重跑:')
616
+ fail(' sudo npx -y @2api-store/activate reconfigure')
617
+ return 2
618
+ }
619
+
620
+ return failed.length > 0 ? 3 : 0
621
+ }
622
+
623
+ // ============================================================
624
+ // refresh-groups:刷新分组列表(只展示)
625
+ // ============================================================
626
+
627
+ async function cmdRefreshGroups(argv) {
628
+ const args = parseSubArgs(argv)
629
+ if (args.help) {
630
+ console.log(`npx @2api-store/activate refresh-groups [options]\n\n${SUB_HELP}`)
631
+ return 0
632
+ }
633
+
634
+ banner()
635
+ const env = detectAll()
636
+ if (!env.node.supported) {
637
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
638
+ return 1
639
+ }
640
+
641
+ const apiBase = args.apiBase || process.env.ACTIVATE_API_BASE || DEFAULT_API_BASE
642
+ info(`API 端点:${apiBase}`)
643
+ console.log()
644
+
645
+ step('获取可用分组')
646
+ let groups = []
647
+ try {
648
+ groups = await listGroups({ apiBase })
649
+ } catch (err) {
650
+ fail(`获取分组失败:${err.message}`)
651
+ return 1
652
+ }
653
+
654
+ if (groups.length === 0) {
655
+ warn('未查询到可用分组')
656
+ info('可能原因:服务端未配置分组 / 网络错误 / 端点错误')
657
+ info(`当前端点:${apiBase}(可通过 --api-base 或 ACTIVATE_API_BASE 覆盖)`)
658
+ return 0
659
+ }
660
+
661
+ ok(`查询到 ${groups.length} 个可用分组`)
662
+ console.log()
663
+
664
+ // 彩色表格输出
665
+ step('分组列表')
666
+ const idWidth = Math.max(4, ...groups.map((g) => String(g.id ?? '').length))
667
+ const nameWidth = Math.max(6, ...groups.map((g) => String(g.name ?? '').length))
668
+ const header = ` ${'ID'.padEnd(idWidth)} ${'名称'.padEnd(nameWidth)} 说明`
669
+ plain(chalk.bold.cyan(header))
670
+ plain(chalk.dim(' ' + '─'.repeat(idWidth + nameWidth + 15)))
671
+ groups.forEach((g, i) => {
672
+ const id = String(g.id ?? '').padEnd(idWidth)
673
+ const name = String(g.name ?? '').padEnd(nameWidth)
674
+ const row = ` ${chalk.yellow(id)} ${chalk.green(name)}`
675
+ plain(row)
676
+ })
677
+ console.log()
678
+
679
+ info('💡 如需切换分组:')
680
+ plain(' • 已有 API Key 绑组:登录管理后台修改,或重新激活(npx @2api-store/activate)')
681
+ plain(' • 使用兑换码:重新激活时选目标分组即可')
682
+ info('(本命令仅展示分组,不修改 API Key 或配置文件)')
683
+
684
+ return 0
685
+ }
686
+
687
+ // ============================================================
688
+ // 子命令注册表
689
+ // ============================================================
690
+
691
+ export const SUBCOMMANDS = {
692
+ restore: { handler: cmdRestore, description: '从备份恢复配置' },
693
+ reset: { handler: cmdReset, description: '恢复官方默认(删 2api.store 配置并从备份恢复)' },
694
+ reconfigure: { handler: cmdReconfigure, description: '用已有 API Key 重新拉取并写入配置' },
695
+ 'refresh-groups': { handler: cmdRefreshGroups, description: '查看可用分组(不改配置)' },
696
+ }
697
+
698
+ /** 子命令名集合(用于 argv[2] 分发判断) */
699
+ export const SUBCOMMAND_NAMES = new Set(Object.keys(SUBCOMMANDS))
700
+
701
+ /** 帮助文本中的子命令清单 */
702
+ export { SUB_HELP }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2api-store/activate",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "TokenShop CLI 一键激活:配置本地 codex/claude/gemini/grok/opencode 指向 2api.store 中转(彩色输出 + 自动安装 CLI)",
5
5
  "type": "module",
6
6
  "bin": {