@2api-store/activate 0.3.7 → 0.4.1

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
+ | `reconfig` | 重新配置:输入已有 `sk-` API Key → 重拉最新模板/模型 → 覆盖写入配置(写前备份)。同激活流程但不创建账号 |
41
+ | `refresh-groups` | 查看可用分组:`GET /cli/groups` → 彩色表格展示。只读,不改 key/配置 |
42
+
43
+ 所有子命令支持 `--api-base <url>` 覆盖端点;`restore` / `reset` / `reconfig` 支持 `--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
+ ### reconfig — 重新配置
69
+
70
+ ```bash
71
+ npx @2api-store/activate reconfig
72
+ npx @2api-store/activate reconfig --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 → 配置包
@@ -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()
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,764 @@
1
+ /**
2
+ * 子命令实现:restore / reset / reconfig / refresh-groups
3
+ *
4
+ * 设计原则:
5
+ * - 每个子命令是独立 async 函数,接收 argv 数组(不含子命令名本身)
6
+ * - 返回退出码(0 成功 / 1 输入错误 / 2 权限 / 3 写入失败)
7
+ * - 自包含:解析自己的 argv + 走完整流程
8
+ * - 操作配置文件前都备份(reset 删前备份;reconfig 写前备份)
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
+ reconfig 重新配置(输入已有 sk- API Key → 重拉模板/模型 → 覆盖写入)
130
+ refresh-groups 刷新分组列表(只展示,不改 key/配置)
131
+
132
+ 所有子命令都支持 --api-base <url> 覆盖端点,restore/reset/reconfig 支持 --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
+
167
+ // 按应用分组(根据 origPath 的父目录)
168
+ const APP_LABELS = {
169
+ '.codex': 'Codex CLI',
170
+ '.claude': 'Claude Code',
171
+ '.gemini': 'Gemini CLI',
172
+ }
173
+ function appLabel(origPath) {
174
+ const home = process.env.HOME || process.env.USERPROFILE || ''
175
+ const rel = path.relative(home, origPath).replace(/\\/g, '/')
176
+ const firstSeg = rel.split('/')[0]
177
+ return APP_LABELS[firstSeg] || firstSeg
178
+ }
179
+ function appDir(origPath) {
180
+ const home = process.env.HOME || process.env.USERPROFILE || ''
181
+ return path.dirname(origPath)
182
+ }
183
+
184
+ // 按应用(目录)分组
185
+ const byApp = {}
186
+ for (const b of backups) {
187
+ const dir = appDir(b.origPath)
188
+ if (!byApp[dir]) byApp[dir] = []
189
+ byApp[dir].push(b)
190
+ }
191
+
192
+ const appDirs = Object.keys(byApp)
193
+ if (appDirs.length === 0) {
194
+ info('无可恢复的备份')
195
+ return 0
196
+ }
197
+
198
+ console.log()
199
+ step('选择要恢复的应用')
200
+ const appItems = appDirs.map((dir) => {
201
+ const label = appLabel(path.join(dir, 'dummy'))
202
+ const count = byApp[dir].length
203
+ return { label: `${chalk.cyan(label)}(${count} 个备份)`, dir }
204
+ })
205
+ const appIdx = await select('选择应用(回车=第一个)', appItems, { defaultIndex: 0 })
206
+ if (appIdx < 0 || appIdx >= appItems.length) {
207
+ fail('未选择应用')
208
+ return 1
209
+ }
210
+ const chosenDir = appItems[appIdx].dir
211
+ const chosenAppLabel = appLabel(path.join(chosenDir, 'dummy'))
212
+
213
+ // 该应用的备份按时间戳分组(截取到秒:T08-23-03)
214
+ const appBackups = byApp[chosenDir].sort((a, b) => b.mtime - a.mtime)
215
+ const byTime = {}
216
+ const timeOrder = []
217
+ for (const b of appBackups) {
218
+ // 从 bakPath 提取时间戳前缀(到秒)
219
+ const m = path.basename(b.bakPath).match(/\.bak\.(.+)$/)
220
+ const fullTs = m ? m[1] : ''
221
+ // 截取到秒:去毫秒后缀(最后 4 字符:-XXXZ)
222
+ const tsKey = fullTs.replace(/-\d{3}Z$/, 'Z')
223
+ if (!byTime[tsKey]) {
224
+ byTime[tsKey] = []
225
+ timeOrder.push(tsKey)
226
+ }
227
+ byTime[tsKey].push(b)
228
+ }
229
+
230
+ console.log()
231
+ step(`选择恢复时间点(${chosenAppLabel})`)
232
+ const timeItems = timeOrder.map((ts) => {
233
+ const group = byTime[ts]
234
+ const files = group.map((b) => path.basename(b.origPath)).join(', ')
235
+ const tsDisplay = fmtTime(group[0].mtime)
236
+ return { label: `${chalk.dim(tsDisplay)}(${group.length} 文件:${files})`, ts, group }
237
+ })
238
+ const timeIdx = await select('选择时间点(回车=最新)', timeItems, { defaultIndex: 0 })
239
+ if (timeIdx < 0 || timeIdx >= timeItems.length) {
240
+ fail('未选择时间点')
241
+ return 1
242
+ }
243
+ const chosenGroup = timeItems[timeIdx].group
244
+
245
+ console.log()
246
+ info(`将恢复 ${chosenAppLabel}(${chosenGroup.length} 个文件):`)
247
+ for (const b of chosenGroup) {
248
+ info(` ${path.basename(b.origPath)} ← ${path.basename(b.bakPath)}`)
249
+ }
250
+
251
+ if (args.dryRun) {
252
+ warn('[试运行] 不真实恢复')
253
+ return 0
254
+ }
255
+
256
+ // 恢复前安全备份当前文件(方便撤销)
257
+ let restored = 0
258
+ for (const b of chosenGroup) {
259
+ if (fs.existsSync(b.origPath)) {
260
+ try {
261
+ const safetyBak = backupFile(b.origPath)
262
+ if (safetyBak) info(`安全备份:${path.basename(b.origPath)} → ${path.basename(safetyBak)}`)
263
+ } catch {
264
+ // 安全备份失败不阻塞
265
+ }
266
+ }
267
+ try {
268
+ restoreBackup(b.bakPath, b.origPath)
269
+ ok(`已恢复:${b.origPath}`)
270
+ restored++
271
+ } catch (err) {
272
+ fail(`恢复失败:${b.origPath}(${err.message})`)
273
+ }
274
+ }
275
+ info(`共恢复 ${restored} 个文件`)
276
+ return restored > 0 ? 0 : 3
277
+ }
278
+
279
+ // ============================================================
280
+ // reset:恢复官方默认
281
+ // ============================================================
282
+
283
+ async function cmdReset(argv) {
284
+ const args = parseSubArgs(argv)
285
+ if (args.help) {
286
+ console.log(`npx @2api-store/activate reset [options]\n\n${SUB_HELP}`)
287
+ return 0
288
+ }
289
+
290
+ banner()
291
+ const env = detectAll()
292
+ if (!env.node.supported) {
293
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
294
+ return 1
295
+ }
296
+
297
+ step('定位 2api.store 写入的配置')
298
+ const managedFiles = getManagedFiles(env.os.isWindows)
299
+ const existing = managedFiles.filter((p) => {
300
+ try {
301
+ return fs.existsSync(p)
302
+ } catch {
303
+ return false
304
+ }
305
+ })
306
+
307
+ if (existing.length === 0) {
308
+ warn('未发现 2api.store 写入的配置文件(可能已 reset 或从未激活过)')
309
+ info('如需从备份恢复,请运行:npx @2api-store/activate restore')
310
+ return 0
311
+ }
312
+
313
+ info('将处理以下文件:')
314
+ existing.forEach((p) => plain(` - ${chalk.yellow(p)}`))
315
+ console.log()
316
+
317
+ // 扫描最新备份
318
+ const dirs = getBackupScanDirs(env.os.isWindows)
319
+ const allBackups = listBackups(dirs)
320
+ const hasBackups = allBackups.length > 0
321
+ if (hasBackups) {
322
+ ok(`发现 ${allBackups.length} 个备份文件(reset 后将尝试恢复每个配置的最新备份)`)
323
+ } else {
324
+ warn('未找到任何备份文件(reset 后这些文件将直接删除,无备份可恢复)')
325
+ }
326
+
327
+ console.log()
328
+ step('确认操作')
329
+ warn('⚠️ 这是破坏性操作:将删除上述配置文件并尝试从备份恢复。')
330
+ warn(' 删除前会再备份一次(.bak.reset.<时间戳>),可手动恢复。')
331
+ const sure = await confirm('确认 reset 吗?', { defaultValue: false })
332
+ if (!sure) {
333
+ plain('已取消')
334
+ return 0
335
+ }
336
+ console.log()
337
+
338
+ if (args.dryRun) {
339
+ warn('【试运行模式 --dry-run】不会真实删除/恢复文件')
340
+ info('将执行:')
341
+ existing.forEach((p) => plain(` - 删除 ${p}`))
342
+ if (hasBackups) {
343
+ info('并从最新备份恢复:')
344
+ allBackups.slice(0, 5).forEach((b) => plain(` - ${b.bakPath} → ${b.origPath}`))
345
+ if (allBackups.length > 5) plain(` ...(共 ${allBackups.length} 个备份)`)
346
+ }
347
+ return 0
348
+ }
349
+
350
+ // 1) 删除前再备份一次(.bak.reset.<ts>),保险可撤销
351
+ step('安全备份当前文件')
352
+ const safetyBackups = []
353
+ for (const p of existing) {
354
+ try {
355
+ // 用自定义后缀,避免与 listBackups 的常规 .bak.<ts> 混淆
356
+ const ts = new Date().toISOString().replace(/[:.]/g, '-')
357
+ const safetyPath = `${p}.bak.reset.${ts}`
358
+ fs.copyFileSync(p, safetyPath)
359
+ safetyBackups.push(safetyPath)
360
+ plain(` ✓ ${p} → ${safetyPath}`)
361
+ } catch (err) {
362
+ fail(`安全备份失败 ${p}:${err.message}(跳过删除此文件避免误伤)`)
363
+ }
364
+ }
365
+ if (safetyBackups.length > 0) {
366
+ ok(`已安全备份 ${safetyBackups.length} 个文件`)
367
+ }
368
+ console.log()
369
+
370
+ // 2) 删除 managed 文件
371
+ step('删除 2api.store 配置')
372
+ const deleted = []
373
+ for (const p of existing) {
374
+ try {
375
+ fs.unlinkSync(p)
376
+ ok(`已删除:${p}`)
377
+ deleted.push(p)
378
+ } catch (err) {
379
+ if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'EROFS') {
380
+ fail(`权限不足,无法删除 ${p}(请用 sudo / 管理员 PowerShell 重跑)`)
381
+ } else {
382
+ fail(`删除失败 ${p}:${err.message}`)
383
+ }
384
+ }
385
+ }
386
+ console.log()
387
+
388
+ // 3) 从最新备份恢复(按 origPath 分组,取每个 origPath 的最新备份)
389
+ if (hasBackups) {
390
+ step('从最新备份恢复')
391
+ // 按 origPath 分组,取每组最新的
392
+ const byOrig = new Map()
393
+ for (const b of allBackups) {
394
+ const cur = byOrig.get(b.origPath)
395
+ if (!cur || b.mtime > cur.mtime) byOrig.set(b.origPath, b)
396
+ }
397
+ let restoredCount = 0
398
+ for (const [origPath, b] of byOrig) {
399
+ try {
400
+ restoreBackup(b.bakPath, origPath)
401
+ ok(`已恢复:${b.bakPath} → ${origPath}`)
402
+ restoredCount++
403
+ } catch (err) {
404
+ warn(`恢复失败 ${origPath}:${err.message}(文件已删除,未恢复)`)
405
+ }
406
+ }
407
+ if (restoredCount > 0) info(`从备份恢复 ${restoredCount} 个文件`)
408
+ console.log()
409
+ }
410
+
411
+ // 报告
412
+ step('reset 报告')
413
+ ok(`已删除 ${deleted.length} 个 2api.store 配置文件`)
414
+ if (safetyBackups.length > 0) {
415
+ info('安全备份位置(如需撤销 reset,手动恢复这些文件):')
416
+ safetyBackups.forEach((b) => plain(` - ${b}`))
417
+ }
418
+ info('下一步:如需重新激活,运行 npx @2api-store/activate')
419
+
420
+ return deleted.length > 0 ? 0 : 1
421
+ }
422
+
423
+ // ============================================================
424
+ // reconfig:重新配置
425
+ // ============================================================
426
+
427
+ async function cmdReconfig(argv) {
428
+ const args = parseSubArgs(argv)
429
+ if (args.help) {
430
+ console.log(`npx @2api-store/activate reconfig [options]\n\n${SUB_HELP}`)
431
+ return 0
432
+ }
433
+
434
+ banner()
435
+ const env = detectAll()
436
+ const osLabel = env.os.isMac
437
+ ? 'macOS'
438
+ : env.os.isLinux
439
+ ? 'Linux'
440
+ : env.os.isWindows
441
+ ? 'Windows'
442
+ : env.os.platform
443
+ info(`环境:${osLabel} · node v${env.node.version}`)
444
+ if (!env.node.supported) {
445
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
446
+ return 1
447
+ }
448
+
449
+ const apiBase = args.apiBase || process.env.ACTIVATE_API_BASE || DEFAULT_API_BASE
450
+ info(`API 端点:${apiBase}`)
451
+ if (args.dryRun) warn('【试运行模式 --dry-run】不会真写配置')
452
+ console.log()
453
+
454
+ // 输入 API Key
455
+ const apiKey = (await askSecret('请输入现有的 API Key (sk-...)')).trim()
456
+ if (!apiKey) {
457
+ fail('未输入 API Key')
458
+ return 1
459
+ }
460
+ if (!apiKey.startsWith('sk-')) {
461
+ fail('reconfig 仅支持 API Key 模式(sk- 前缀)。如需使用兑换码,请运行默认激活流程:npx @2api-store/activate')
462
+ return 1
463
+ }
464
+ info('识别为:API Key 模式(不创建账号,仅重拉模板写入配置)')
465
+ console.log()
466
+
467
+ // 询问是否限制平台(可选)
468
+ const platformAnswer = (await ask('限制平台?(留空=全部,或输入 openai/anthropic/gemini/grok/antigravity)')).trim()
469
+ const platform = platformAnswer || null
470
+ if (platform) info(`平台过滤:${platform}`)
471
+ console.log()
472
+
473
+ // POST /cli/activate
474
+ step('请求激活服务(key 模式重拉模板)')
475
+ let bundle
476
+ try {
477
+ bundle = await cliActivate({ credential: apiKey, platform, apiBase })
478
+ } catch (err) {
479
+ fail(`请求失败:${err.message}`)
480
+ if (err.statusCode) fail(` HTTP ${err.statusCode}`)
481
+ return 1
482
+ }
483
+ if (bundle.mode && bundle.mode !== 'key') {
484
+ warn(`服务端返回模式:${bundle.mode}(预期 key 模式,但继续)`)
485
+ }
486
+ ok('已获取最新配置模板')
487
+ if (bundle.base_url) info(`base_url:${bundle.base_url}`)
488
+ console.log()
489
+
490
+ // 过滤配置
491
+ const allowedPlatforms = new Set()
492
+ for (const m of bundle.models || []) {
493
+ const id = (typeof m === 'string' ? m : m?.id || '').toLowerCase()
494
+ if (/^(gpt|o1|o3|codex|glm|minimax|deepseek)/.test(id)) allowedPlatforms.add('openai')
495
+ else if (id.startsWith('claude')) allowedPlatforms.add('anthropic')
496
+ else if (id.startsWith('gemini')) allowedPlatforms.add('gemini')
497
+ else if (id.startsWith('grok')) allowedPlatforms.add('grok')
498
+ }
499
+ if (platform) allowedPlatforms.add(platform)
500
+
501
+ const osConfigs = (bundle.configs || []).filter((c) => {
502
+ if (!c) return false
503
+ if (allowedPlatforms.size > 0 && !allowedPlatforms.has(c.platform)) return false
504
+ if (platform && c.platform !== platform) return false
505
+ if (env.os.isWindows) {
506
+ return c.os_type === 'windows' || c.os_type === 'all'
507
+ }
508
+ return c.os_type === 'unix' || c.os_type === 'all'
509
+ })
510
+
511
+ if (osConfigs.length === 0) {
512
+ fail('服务端未返回适配当前系统的配置模板')
513
+ return 1
514
+ }
515
+
516
+ // dedupe by platform + client_type
517
+ const seen = new Set()
518
+ const cliItems = []
519
+ for (const c of osConfigs) {
520
+ const key = `${c.platform}/${c.client_type}`
521
+ if (seen.has(key)) continue
522
+ seen.add(key)
523
+ const meta = getCliMeta(c.client_type)
524
+ const displayName = c.display_name || c.displayName || meta?.name || `${c.platform} ${c.client_type}`
525
+ const installed = env.installed[c.client_type]?.path || (meta && env.installed[meta.detectBin]?.path)
526
+ const primaryMark = isDefaultSelected(c.client_type) ? '' : '(独立 CLI)'
527
+ const nameColored = installed ? chalk.green(displayName) : chalk.gray(`${displayName} (未装)`)
528
+ cliItems.push({
529
+ label: `${nameColored} ${chalk.dim(`[${c.platform}/${c.client_type}]`)}${primaryMark}`,
530
+ config: c,
531
+ clientType: c.client_type,
532
+ meta,
533
+ alreadyInstalled: !!installed,
534
+ })
535
+ }
536
+
537
+ const defaultSelected = cliItems
538
+ .map((item, i) => ({ item, i }))
539
+ .filter(({ item }) => isDefaultSelected(item.clientType))
540
+ .map(({ i }) => i)
541
+
542
+ step('选择要重配的 CLI')
543
+ const selectedIdxes = await multiSelect(
544
+ '选择 CLI(绿色=已装,灰色=未装;默认选主 CLI)',
545
+ cliItems,
546
+ { defaultSelected },
547
+ )
548
+ if (selectedIdxes.length === 0) {
549
+ plain('未选择任何 CLI,退出。')
550
+ return 0
551
+ }
552
+ const selectedItems = selectedIdxes.map((i) => cliItems[i])
553
+ console.log()
554
+
555
+ // 选默认模型
556
+ const modelItems = listModels(bundle.models || [])
557
+ let defaultModelId = ''
558
+ if (modelItems.length > 0) {
559
+ step('选择默认模型')
560
+ const TOP_N = 10
561
+ const topItems = modelItems.slice(0, TOP_N)
562
+ const hasMore = modelItems.length > TOP_N
563
+ const selectItems = [
564
+ ...topItems.map((m) => ({ label: `${m.label} (${m.id})` })),
565
+ ...(hasMore ? [{ label: chalk.dim('其他(手动输入)') }] : []),
566
+ ]
567
+ const idx = await select('选择默认模型', selectItems, { defaultIndex: 0 })
568
+ if (hasMore && idx === topItems.length) {
569
+ defaultModelId = (await ask('输入模型 ID')).trim() || topItems[0].id
570
+ } else {
571
+ defaultModelId = topItems[idx].id
572
+ }
573
+ ok(`已选默认模型:${defaultModelId}`)
574
+ } else {
575
+ warn('服务端未返回模型列表,{{defaultModel}} 将留空')
576
+ }
577
+ console.log()
578
+
579
+ // 渲染
580
+ const baseUrl = bundle.base_url || ''
581
+ const baseUrlBeta = bundle.base_url_beta || baseUrl
582
+ const renderValues = {
583
+ apiKey: bundle.api_key || apiKey,
584
+ baseUrl,
585
+ apiBase: baseUrl,
586
+ apiBaseBeta: baseUrlBeta,
587
+ antigravityApiBase: `${baseUrl}/antigravity`,
588
+ antigravityApiBaseBeta: `${baseUrlBeta}/antigravity`,
589
+ defaultModel: defaultModelId,
590
+ }
591
+
592
+ // 备份 + 写入
593
+ step('备份并写入配置')
594
+ const report = []
595
+ let firstPermissionError = null
596
+
597
+ for (const item of selectedItems) {
598
+ const displayName =
599
+ item.config.display_name || item.config.displayName || item.meta?.name || item.clientType
600
+ plain(`── ${displayName} ──`)
601
+ const files = item.config.files || []
602
+ for (const f of files) {
603
+ const rawPath = f.path || ''
604
+ const isShellOnly = rawPath === 'Terminal' || rawPath === 'PowerShell'
605
+ const targetPath = isShellOnly
606
+ ? rawPath
607
+ : expandPath(rawPath, { isWindows: env.os.isWindows })
608
+ const rendered = renderContent(f.content || '', renderValues)
609
+
610
+ if (isShellOnly) {
611
+ plain(' [shell 命令,请手动执行]')
612
+ plain(rendered.split('\n').map((l) => ' ' + l).join('\n'))
613
+ report.push({ cli: displayName, path: rawPath, action: 'shell-only' })
614
+ continue
615
+ }
616
+
617
+ plain(` 目标:${targetPath}`)
618
+
619
+ if (args.dryRun) {
620
+ warn(' [试运行] 不真实写入')
621
+ report.push({ cli: displayName, path: targetPath, action: 'dry-run' })
622
+ console.log()
623
+ continue
624
+ }
625
+
626
+ let backupPath = null
627
+ try {
628
+ backupPath = backupFile(targetPath)
629
+ if (backupPath) plain(` 备份:${backupPath}`)
630
+ } catch (err) {
631
+ fail(`备份失败:${err.message}(跳过此文件避免无备份覆盖)`)
632
+ report.push({ cli: displayName, path: targetPath, action: 'backup-failed', error: err.message })
633
+ continue
634
+ }
635
+
636
+ try {
637
+ writeFileWithPermission(targetPath, rendered, { isWindows: env.os.isWindows })
638
+ ok('已写入')
639
+ report.push({ cli: displayName, path: targetPath, action: 'written', backup: backupPath })
640
+ } catch (err) {
641
+ if (err instanceof PermissionDeniedError) {
642
+ if (!firstPermissionError) firstPermissionError = err
643
+ fail(err.message)
644
+ report.push({ cli: displayName, path: targetPath, action: 'permission-denied', error: err.message })
645
+ } else {
646
+ fail(`写入失败:${err.message}`)
647
+ report.push({ cli: displayName, path: targetPath, action: 'failed', error: err.message })
648
+ }
649
+ }
650
+ }
651
+ console.log()
652
+ }
653
+
654
+ // 报告
655
+ step('重配报告')
656
+ const written = report.filter((r) => r.action === 'written')
657
+ const failed = report.filter(
658
+ (r) => r.action === 'failed' || r.action === 'permission-denied' || r.action === 'backup-failed',
659
+ )
660
+ if (args.dryRun) {
661
+ plain(`[试运行] 将写入 ${report.filter((r) => r.action === 'dry-run').length} 个文件`)
662
+ } else {
663
+ ok(`已写入 ${written.length} 个文件`)
664
+ }
665
+ if (failed.length > 0) {
666
+ warn(`失败 ${failed.length} 项:`)
667
+ failed.forEach((f) => warn(` - ${f.cli} ${f.path}`))
668
+ }
669
+ const backups = report.filter((r) => r.backup).map((r) => r.backup)
670
+ if (backups.length > 0) {
671
+ info('备份位置:')
672
+ backups.forEach((b) => plain(` - ${b}`))
673
+ }
674
+
675
+ if (firstPermissionError) {
676
+ console.log()
677
+ fail('部分文件权限不足,请按提示用 sudo / 管理员 PowerShell 重跑:')
678
+ fail(' sudo npx -y @2api-store/activate reconfig')
679
+ return 2
680
+ }
681
+
682
+ return failed.length > 0 ? 3 : 0
683
+ }
684
+
685
+ // ============================================================
686
+ // refresh-groups:刷新分组列表(只展示)
687
+ // ============================================================
688
+
689
+ async function cmdRefreshGroups(argv) {
690
+ const args = parseSubArgs(argv)
691
+ if (args.help) {
692
+ console.log(`npx @2api-store/activate refresh-groups [options]\n\n${SUB_HELP}`)
693
+ return 0
694
+ }
695
+
696
+ banner()
697
+ const env = detectAll()
698
+ if (!env.node.supported) {
699
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
700
+ return 1
701
+ }
702
+
703
+ const apiBase = args.apiBase || process.env.ACTIVATE_API_BASE || DEFAULT_API_BASE
704
+ info(`API 端点:${apiBase}`)
705
+ console.log()
706
+
707
+ step('获取可用分组')
708
+ let groups = []
709
+ try {
710
+ groups = await listGroups({ apiBase })
711
+ } catch (err) {
712
+ fail(`获取分组失败:${err.message}`)
713
+ return 1
714
+ }
715
+
716
+ if (groups.length === 0) {
717
+ warn('未查询到可用分组')
718
+ info('可能原因:服务端未配置分组 / 网络错误 / 端点错误')
719
+ info(`当前端点:${apiBase}(可通过 --api-base 或 ACTIVATE_API_BASE 覆盖)`)
720
+ return 0
721
+ }
722
+
723
+ ok(`查询到 ${groups.length} 个可用分组`)
724
+ console.log()
725
+
726
+ // 彩色表格输出
727
+ step('分组列表')
728
+ const idWidth = Math.max(4, ...groups.map((g) => String(g.id ?? '').length))
729
+ const nameWidth = Math.max(6, ...groups.map((g) => String(g.name ?? '').length))
730
+ const header = ` ${'ID'.padEnd(idWidth)} ${'名称'.padEnd(nameWidth)} 说明`
731
+ plain(chalk.bold.cyan(header))
732
+ plain(chalk.dim(' ' + '─'.repeat(idWidth + nameWidth + 15)))
733
+ groups.forEach((g, i) => {
734
+ const id = String(g.id ?? '').padEnd(idWidth)
735
+ const name = String(g.name ?? '').padEnd(nameWidth)
736
+ const row = ` ${chalk.yellow(id)} ${chalk.green(name)}`
737
+ plain(row)
738
+ })
739
+ console.log()
740
+
741
+ info('💡 如需切换分组:')
742
+ plain(' • 已有 API Key 绑组:登录管理后台修改,或重新激活(npx @2api-store/activate)')
743
+ plain(' • 使用兑换码:重新激活时选目标分组即可')
744
+ info('(本命令仅展示分组,不修改 API Key 或配置文件)')
745
+
746
+ return 0
747
+ }
748
+
749
+ // ============================================================
750
+ // 子命令注册表
751
+ // ============================================================
752
+
753
+ export const SUBCOMMANDS = {
754
+ restore: { handler: cmdRestore, description: '从备份恢复配置' },
755
+ reset: { handler: cmdReset, description: '恢复官方默认(删 2api.store 配置并从备份恢复)' },
756
+ reconfig: { handler: cmdReconfig, description: '用已有 API Key 重新拉取并写入配置' },
757
+ 'refresh-groups': { handler: cmdRefreshGroups, description: '查看可用分组(不改配置)' },
758
+ }
759
+
760
+ /** 子命令名集合(用于 argv[2] 分发判断) */
761
+ export const SUBCOMMAND_NAMES = new Set(Object.keys(SUBCOMMANDS))
762
+
763
+ /** 帮助文本中的子命令清单 */
764
+ export { SUB_HELP }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2api-store/activate",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "TokenShop CLI 一键激活:配置本地 codex/claude/gemini/grok/opencode 指向 2api.store 中转(彩色输出 + 自动安装 CLI)",
5
5
  "type": "module",
6
6
  "bin": {