@2api-store/activate 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/activate.mjs CHANGED
@@ -1,17 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * @2api-store/activate — TokenShop CLI 一键激活
3
+ * @2api-store/activate — TokenShop CLI 一键激活(v0.3.0)
4
4
  *
5
- * 主流程(spec §5.2):
6
- * 1. 检测环境(OS / node / 已装 CLI)
7
- * 2. 输入凭据(sk- 前缀 → API Key;否则兑换码)
8
- * 3. 兑换码模式 → 补输入 email
9
- * 4. POST /api/v1/cli/activate → 拿配置包
10
- * 5. 按 os_type 过滤配置,展示可用 CLI 让用户多选
11
- * 6. 展示可用模型让用户选 defaultModel
12
- * 7. 备份 → 渲染(注入 apiKey/baseUrl/defaultModel)→ 写入(两阶段权限)
13
- * 8. 验证:GET /v1/models(Bearer api_key)确认 200
14
- * 9. 报告:激活清单 / 备份位置 / (新用户)查收邮件
5
+ * 主流程(薄编排,逻辑在各 lib 模块):
6
+ * 1. banner + 检测环境 + 列已装/未装 CLI
7
+ * 2. 输入凭据(sk- → API Key;否则兑换码 + email)
8
+ * 3. POST /api/v1/cli/activate → 配置包
9
+ * 4. 多选 CLI(主 CLI 默认选;opencode 默认不选;未装的标 ✗)
10
+ * 5. 未装 CLI → ensureInstalled(询问确认 → npm i -g)
11
+ * 6. 选默认模型
12
+ * 7. 备份 → 渲染 → 写入(每个文件彩色 ok/fail)
13
+ * 8. 验证 API Key(GET /v1/models)
14
+ * 9. 报告 + 打印启动/使用指南(每个激活 CLI 的 startCmd + usage)
15
15
  */
16
16
 
17
17
  import { detectAll } from './lib/detect.mjs'
@@ -27,18 +27,16 @@ import {
27
27
  confirm,
28
28
  closePrompt,
29
29
  } from './lib/prompt.mjs'
30
+ import { banner, ok, fail, warn, info, step, guide, plain } from './lib/ui.mjs'
31
+ import { getCliMeta, isDefaultSelected } from './lib/platforms.mjs'
32
+ import { ensureInstalled } from './lib/install.mjs'
30
33
 
31
- const VERSION = '0.1.0'
34
+ const VERSION = '0.3.0'
32
35
  const DEFAULT_API_BASE = 'https://2api.store'
33
36
 
34
- const BANNER = String.raw`
35
- _ _ _ _ ____ _ ____ _ _
36
- | | | | | | | | / ___| / \ | __ ) | \ | |
37
- | |_| | | | | || | _ / _ \ | _ \ | \| |
38
- | _ | | |_| || |_| |/ ___ \| |_) | | |\ |
39
- |_| |_| \___/ \____/_/ \_\____/ |_| \_|
40
- TokenShop CLI 一键激活 v${VERSION}
41
- `
37
+ // ============================================================
38
+ // 参数解析 / 帮助 / 凭据分流
39
+ // ============================================================
42
40
 
43
41
  function parseArgs(argv) {
44
42
  const args = { dryRun: false, platform: null, _: [] }
@@ -74,7 +72,7 @@ function printHelp() {
74
72
  npx @2api-store/activate [options]
75
73
 
76
74
  选项:
77
- --dry-run, -n 不真写配置,只展示将写入什么
75
+ --dry-run, -n 不真写配置/不真装 CLI,只展示将做什么
78
76
  --platform <name> 只激活指定平台(openai/anthropic/gemini/grok/antigravity)
79
77
  --api-base <url> 覆盖 API 端点(默认 $ACTIVATE_API_BASE 或 ${DEFAULT_API_BASE})
80
78
  -h, --help 显示帮助
@@ -98,22 +96,17 @@ function printHelp() {
98
96
  }
99
97
 
100
98
  /**
101
- * 凭据格式分流(spec §5.3)
102
- * sk- 前缀 → key
103
- * 32 字符 [a-zA-Z0-9] → redeem
99
+ * 凭据格式分流:sk- → key;16-64 字符 [a-zA-Z0-9] → redeem
104
100
  */
105
101
  function classifyCredential(cred) {
106
102
  if (!cred) return 'empty'
107
103
  if (cred.startsWith('sk-')) return 'key'
108
- // 兑换码:16-64 字符 [a-zA-Z0-9]
109
104
  if (/^[a-zA-Z0-9]{16,64}$/.test(cred)) return 'redeem'
110
105
  return 'unknown'
111
106
  }
112
107
 
113
108
  /**
114
- * 从模型 ID 列表推断平台集合(与端点 inferPlatforms 对应,脚本兜底过滤)。
115
- * 即使生产端点尚未部署过滤(0.314.0),脚本也按 key 分组实际可用模型平台过滤 configs,
116
- * 避免给 codex key 列出 gemini/claude 等。
109
+ * 从模型 ID 列表推断平台集合(脚本兜底,与端点 inferPlatforms 对应)
117
110
  */
118
111
  function inferModelPlatforms(models) {
119
112
  const set = new Set()
@@ -127,6 +120,10 @@ function inferModelPlatforms(models) {
127
120
  return [...set]
128
121
  }
129
122
 
123
+ // ============================================================
124
+ // 主流程
125
+ // ============================================================
126
+
130
127
  async function main() {
131
128
  const args = parseArgs(process.argv.slice(2))
132
129
  if (args.help) {
@@ -138,9 +135,8 @@ async function main() {
138
135
  return 0
139
136
  }
140
137
 
141
- console.log(BANNER)
142
-
143
- // === 1. 检测环境 ===
138
+ // === 1. banner + 环境检测 ===
139
+ banner()
144
140
  const env = detectAll()
145
141
  const osLabel = env.os.isMac
146
142
  ? 'macOS'
@@ -149,74 +145,79 @@ async function main() {
149
145
  : env.os.isWindows
150
146
  ? 'Windows'
151
147
  : env.os.platform
152
- console.log(`环境:${osLabel} · node v${env.node.version}`)
148
+ info(`环境:${osLabel} · node v${env.node.version}`)
153
149
  if (!env.node.supported) {
154
- console.error(`✗ 需要 node >= 18,当前 ${env.node.version}`)
150
+ fail(`需要 node >= 18,当前 ${env.node.version}`)
155
151
  return 1
156
152
  }
153
+
154
+ // 列出已装/未装 CLI(彩色)
157
155
  const installedNames = Object.keys(env.installed)
156
+ const installedList = installedNames.map((n) => {
157
+ const v = env.installed[n]?.version
158
+ return v ? `${n}@${v}` : n
159
+ })
158
160
  if (installedNames.length > 0) {
159
- console.log(`已检测到 CLI:${installedNames.join(', ')}`)
161
+ ok(`已检测到 CLI:${installedList.join(', ')}`)
160
162
  } else {
161
- console.log('未检测到 codex/claude/gemini/grok/opencode(不影响激活,仅参考)')
163
+ info('未检测到 codex/claude/gemini/grok/opencode(可在后续步骤按需自动安装)')
162
164
  }
163
165
 
164
166
  const apiBase = args.apiBase || process.env.ACTIVATE_API_BASE || DEFAULT_API_BASE
165
- console.log(`API 端点:${apiBase}`)
166
- if (args.dryRun) console.log('【试运行模式 --dry-run】不会真写任何文件')
167
- if (args.platform) console.log(`平台过滤:${args.platform}`)
167
+ info(`API 端点:${apiBase}`)
168
+ if (args.dryRun) warn('【试运行模式 --dry-run】不会真写文件、不会真装 CLI')
169
+ if (args.platform) info(`平台过滤:${args.platform}`)
168
170
  console.log()
169
171
 
170
172
  // === 2. 输入凭据 ===
171
173
  const credential = (await askSecret('请输入 API Key(sk-...)或兑换码')).trim()
172
174
  if (!credential) {
173
- console.error('✗ 未输入凭据')
175
+ fail('未输入凭据')
174
176
  return 1
175
177
  }
176
178
  const kind = classifyCredential(credential)
177
179
  if (kind === 'unknown') {
178
- console.error('✗ 凭据格式无法识别(应为 sk- 前缀的 API Key 或兑换码)')
180
+ fail('凭据格式无法识别(应为 sk- 前缀的 API Key 或兑换码)')
179
181
  return 1
180
182
  }
181
- console.log(`识别为:${kind === 'key' ? 'API Key' : '兑换码'}模式`)
183
+ info(`识别为:${kind === 'key' ? 'API Key' : '兑换码'}模式`)
182
184
 
183
185
  // === 3. 兑换码 → 邮箱 ===
184
186
  let email = ''
185
187
  if (kind === 'redeem') {
186
- email = (await ask('请输入邮箱(用于兑换码绑定 / 新账号创建')).trim()
188
+ email = (await ask('请输入邮箱(用于兑换码绑定 / 新账号创建)')).trim()
187
189
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
188
- console.error('✗ 邮箱格式不正确')
190
+ fail('邮箱格式不正确')
189
191
  return 1
190
192
  }
191
193
  }
192
194
 
193
195
  // === 4. POST /cli/activate ===
194
- console.log()
195
- console.log('正在请求激活服务…')
196
+ step('请求激活服务')
196
197
  let bundle
197
198
  try {
198
199
  bundle = await cliActivate({ credential, email, platform: args.platform, apiBase })
199
200
  } catch (err) {
200
- console.error(`✗ 激活失败:${err.message}`)
201
- if (err.statusCode) console.error(` HTTP ${err.statusCode}`)
201
+ fail(`激活失败:${err.message}`)
202
+ if (err.statusCode) fail(` HTTP ${err.statusCode}`)
202
203
  return 1
203
204
  }
204
205
 
205
- console.log(`✓ 模式:${bundle.mode}${bundle.user?.is_new ? '(新用户已创建)' : ''}`)
206
+ ok(`模式:${bundle.mode}${bundle.user?.is_new ? '(新用户已创建)' : ''}`)
206
207
  if (bundle.user?.is_new && bundle.user?.email) {
207
- console.log(` 新账号:${bundle.user.email}`)
208
- if (bundle.user.message) console.log(` ${bundle.user.message}`)
208
+ info(`新账号:${bundle.user.email}`)
209
+ if (bundle.user.message) info(bundle.user.message)
209
210
  }
210
211
  console.log()
211
212
 
212
- // === 5. 按 os_type + key 分组平台过滤、展示可用 CLI ===
213
+ // === 5. 过滤配置(os_type + 平台) + 多选 CLI(主 CLI 默认选,opencode 默认不选)===
213
214
  const allowedPlatforms = inferModelPlatforms(bundle.models || [])
214
- if (args.platform) allowedPlatforms.push(args.platform)
215
- let osConfigs = (bundle.configs || []).filter((c) => {
215
+ if (args.platform && !allowedPlatforms.includes(args.platform)) {
216
+ allowedPlatforms.push(args.platform)
217
+ }
218
+ const osConfigs = (bundle.configs || []).filter((c) => {
216
219
  if (!c) return false
217
- // 按 key 分组可用模型平台过滤(兜底,与端点 inferPlatforms 一致;codex key 只列 openai)
218
220
  if (allowedPlatforms.length > 0 && !allowedPlatforms.includes(c.platform)) return false
219
- // 客户端 --platform 二次过滤(防御性)
220
221
  if (args.platform && c.platform !== args.platform) return false
221
222
  if (env.os.isWindows) {
222
223
  return c.os_type === 'windows' || c.os_type === 'all'
@@ -225,93 +226,140 @@ async function main() {
225
226
  })
226
227
 
227
228
  if (osConfigs.length === 0) {
228
- console.error('✗ 服务端未返回适配当前系统的配置模板')
229
+ fail('服务端未返回适配当前系统的配置模板')
229
230
  return 1
230
231
  }
231
232
 
232
- // dedupe by platform + client_type
233
+ // dedupe by platform + client_type,构建多选项
233
234
  const seen = new Set()
234
235
  const cliItems = []
235
236
  for (const c of osConfigs) {
236
237
  const key = `${c.platform}/${c.client_type}`
237
238
  if (seen.has(key)) continue
238
239
  seen.add(key)
239
- const displayName = c.display_name || c.displayName || `${c.platform} ${c.client_type}`
240
+ const meta = getCliMeta(c.client_type)
241
+ const displayName = c.display_name || c.displayName || meta?.name || `${c.platform} ${c.client_type}`
242
+ const installed = env.installed[c.client_type]?.path || (meta && env.installed[meta.detectBin]?.path)
243
+ const installMark = installed ? '✓' : '✗'
244
+ const primaryMark = isDefaultSelected(c.client_type) ? '' : '(独立 CLI)'
240
245
  cliItems.push({
241
- label: `${displayName} [${c.platform}/${c.client_type}]`,
246
+ label: `[${installMark}] ${displayName} [${c.platform}/${c.client_type}]${primaryMark}`,
242
247
  config: c,
248
+ clientType: c.client_type,
249
+ meta,
250
+ alreadyInstalled: !!installed,
243
251
  })
244
252
  }
245
253
 
246
- console.log('可用 CLI:')
247
- const selectedIdxes = await multiSelect('选择要激活的 CLI(默认全选)', cliItems, {
248
- defaultSelected: cliItems.map((_, i) => i),
249
- })
254
+ // 默认选中:主 CLI(isPrimary=true)。dry-run 也用此默认(不因 dry-run 改默认)
255
+ const defaultSelected = cliItems
256
+ .map((item, i) => ({ item, i }))
257
+ .filter(({ item }) => isDefaultSelected(item.clientType))
258
+ .map(({ i }) => i)
259
+
260
+ step('选择 CLI')
261
+ const selectedIdxes = await multiSelect(
262
+ '选择要激活的 CLI(✓=已装 ✗=未装;默认选主 CLI,独立 CLI 默认不选)',
263
+ cliItems,
264
+ { defaultSelected },
265
+ )
250
266
  if (selectedIdxes.length === 0) {
251
- console.log('未选择任何 CLI,退出。')
267
+ plain('未选择任何 CLI,退出。')
252
268
  return 0
253
269
  }
254
- const selectedConfigs = selectedIdxes.map((i) => cliItems[i].config)
270
+ const selectedItems = selectedIdxes.map((i) => cliItems[i])
255
271
  console.log()
256
272
 
257
- // === 6. 选 defaultModel ===
273
+ // === 6. 未装 CLI → 询问确认 → npm i -g(dry-run 跳过真装)===
274
+ if (!args.dryRun) {
275
+ step('检查 CLI 安装状态')
276
+ for (const item of selectedItems) {
277
+ if (item.alreadyInstalled) {
278
+ const ver = env.installed[item.clientType]?.version || env.installed[item.meta?.detectBin]?.version
279
+ ok(`${item.meta?.name || item.clientType} 已安装${ver ? `(v${ver})` : ''}`)
280
+ continue
281
+ }
282
+ if (!item.meta || !item.meta.installPackage) {
283
+ // 未知 CLI(如 grok 无官方 npm 包)→ 只提示不装
284
+ warn(`${item.clientType} 无可自动安装的 npm 包,请手动安装后重跑(配置仍会写入)`)
285
+ continue
286
+ }
287
+ info(`未检测到 ${item.meta.name}(命令 ${item.meta.detectBin})`)
288
+ const r = await ensureInstalled(item.meta, { dryRun: args.dryRun })
289
+ if (r.installed) {
290
+ ok(`${item.meta.name} 安装成功${r.version ? `(v${r.version})` : ''}`)
291
+ } else if (r.skipped && r.reason === 'user-declined') {
292
+ warn(`已跳过 ${item.meta.name} 安装(配置仍会写入,但请手动安装后使用)`)
293
+ } else if (r.skipped) {
294
+ info(`已跳过 ${item.meta.name} 安装(${r.reason})`)
295
+ } else {
296
+ fail(`${item.meta.name} 安装失败:${r.error || '未知错误'}(配置仍会写入,但请手动安装后使用)`)
297
+ }
298
+ }
299
+ console.log()
300
+ }
301
+
302
+ // === 7. 选默认模型 ===
258
303
  const modelItems = listModels(bundle.models || [])
259
304
  let defaultModelId = ''
260
305
  if (modelItems.length > 0) {
261
- console.log('可用模型:')
306
+ step('选择默认模型')
262
307
  const selectItems = modelItems.map((m) => ({ label: `${m.label} (${m.id})` }))
263
308
  const idx = await select('选择默认模型', selectItems, { defaultIndex: 0 })
264
309
  defaultModelId = modelItems[idx].id
265
- console.log(`已选默认模型:${defaultModelId}`)
310
+ ok(`已选默认模型:${defaultModelId}`)
266
311
  } else {
267
- console.warn('⚠ 服务端未返回模型列表,{{defaultModel}} 将留空')
312
+ warn('服务端未返回模型列表,{{defaultModel}} 将留空')
268
313
  }
269
314
  console.log()
270
315
 
271
- // 准备渲染值
316
+ // 渲染值
272
317
  const baseUrl = bundle.base_url || ''
273
318
  const baseUrlBeta = bundle.base_url_beta || baseUrl
274
319
  const renderValues = {
275
320
  apiKey: bundle.api_key || '',
276
321
  baseUrl,
277
- apiBase: baseUrl, // alias
322
+ apiBase: baseUrl,
278
323
  apiBaseBeta: baseUrlBeta,
279
324
  antigravityApiBase: `${baseUrl}/antigravity`,
280
325
  antigravityApiBaseBeta: `${baseUrlBeta}/antigravity`,
281
326
  defaultModel: defaultModelId,
282
327
  }
283
328
 
284
- // === 7. 每个选中 CLI 的每个 file:备份 → 渲染 → 写入 ===
329
+ // === 8. 每个选中 CLI 的每个 file:备份 → 渲染 → 写入 ===
330
+ step('备份并写入配置')
285
331
  const report = []
286
332
  let firstPermissionError = null
287
333
 
288
- for (const cfg of selectedConfigs) {
334
+ for (const item of selectedItems) {
289
335
  const displayName =
290
- cfg.display_name || cfg.displayName || `${cfg.platform} ${cfg.client_type}`
291
- console.log(`── ${displayName} ──`)
292
- const files = cfg.files || []
336
+ item.config.display_name || item.config.displayName || item.meta?.name || item.clientType
337
+ plain(`── ${displayName} ──`)
338
+ const files = item.config.files || []
293
339
  for (const f of files) {
294
340
  const rawPath = f.path || ''
295
341
  const isShellOnly = rawPath === 'Terminal' || rawPath === 'PowerShell'
296
- const targetPath = isShellOnly ? rawPath : expandPath(rawPath, { isWindows: env.os.isWindows })
342
+ const targetPath = isShellOnly
343
+ ? rawPath
344
+ : expandPath(rawPath, { isWindows: env.os.isWindows })
297
345
  const rendered = renderContent(f.content || '', renderValues)
298
346
 
299
347
  if (isShellOnly) {
300
- console.log(` [shell 命令,请手动执行]`)
301
- console.log(rendered.split('\n').map((l) => ' ' + l).join('\n'))
348
+ plain(' [shell 命令,请手动执行]')
349
+ plain(rendered.split('\n').map((l) => ' ' + l).join('\n'))
302
350
  report.push({ cli: displayName, path: rawPath, action: 'shell-only' })
303
351
  continue
304
352
  }
305
353
 
306
- console.log(` 目标:${targetPath}`)
354
+ plain(` 目标:${targetPath}`)
307
355
 
308
356
  if (args.dryRun) {
309
- console.log(` [试运行] 不真实写入`)
357
+ warn(' [试运行] 不真实写入')
310
358
  const lines = rendered.split('\n')
311
359
  const preview = lines.slice(0, 8).join('\n')
312
- console.log(' 内容预览:')
313
- console.log(preview.split('\n').map((l) => ' ' + l).join('\n'))
314
- if (lines.length > 8) console.log(` ...(共 ${lines.length} 行)`)
360
+ plain(' 内容预览:')
361
+ plain(preview.split('\n').map((l) => ' ' + l).join('\n'))
362
+ if (lines.length > 8) plain(` ...(共 ${lines.length} 行)`)
315
363
  report.push({ cli: displayName, path: targetPath, action: 'dry-run' })
316
364
  console.log()
317
365
  continue
@@ -321,30 +369,25 @@ async function main() {
321
369
  let backupPath = null
322
370
  try {
323
371
  backupPath = backupFile(targetPath)
324
- if (backupPath) console.log(` 备份:${backupPath}`)
372
+ if (backupPath) plain(` 备份:${backupPath}`)
325
373
  } catch (err) {
326
- console.error(` ✗ 备份失败:${err.message}(跳过此文件避免无备份覆盖)`)
374
+ fail(`备份失败:${err.message}(跳过此文件避免无备份覆盖)`)
327
375
  report.push({ cli: displayName, path: targetPath, action: 'backup-failed', error: err.message })
328
376
  continue
329
377
  }
330
378
 
331
- // 写入(两阶段权限)
379
+ // 写入
332
380
  try {
333
381
  writeFileWithPermission(targetPath, rendered, { isWindows: env.os.isWindows })
334
- console.log(` ✓ 已写入`)
382
+ ok('已写入')
335
383
  report.push({ cli: displayName, path: targetPath, action: 'written', backup: backupPath })
336
384
  } catch (err) {
337
385
  if (err instanceof PermissionDeniedError) {
338
386
  if (!firstPermissionError) firstPermissionError = err
339
- console.error(` ✗ ${err.message}`)
340
- report.push({
341
- cli: displayName,
342
- path: targetPath,
343
- action: 'permission-denied',
344
- error: err.message,
345
- })
387
+ fail(err.message)
388
+ report.push({ cli: displayName, path: targetPath, action: 'permission-denied', error: err.message })
346
389
  } else {
347
- console.error(` ✗ 写入失败:${err.message}`)
390
+ fail(`写入失败:${err.message}`)
348
391
  report.push({ cli: displayName, path: targetPath, action: 'failed', error: err.message })
349
392
  }
350
393
  }
@@ -354,22 +397,21 @@ async function main() {
354
397
 
355
398
  closePrompt()
356
399
 
357
- // === 8. 验证 API Key(非 dry-run) ===
400
+ // === 9. 验证 API Key ===
358
401
  if (!args.dryRun && bundle.api_key && bundle.base_url) {
359
- console.log('验证 API Key…')
402
+ step('验证 API Key')
403
+ info('请求 GET /v1/models…')
360
404
  const v = await verifyApiKey({ apiKey: bundle.api_key, baseUrl: bundle.base_url })
361
405
  if (v.ok) {
362
- console.log(`✓ API Key 验证通过(GET /v1/models → 200)`)
406
+ ok('API Key 验证通过(GET /v1/models → 200)')
363
407
  } else {
364
- console.warn(
365
- `⚠ 验证未通过:${v.error || `HTTP ${v.statusCode}`}(配置已写入,请手动检查)`,
366
- )
408
+ warn(`验证未通过:${v.error || `HTTP ${v.statusCode}`}(配置已写入,请手动检查)`)
367
409
  }
368
410
  console.log()
369
411
  }
370
412
 
371
- // === 9. 报告 ===
372
- console.log('══════════ 激活报告 ══════════')
413
+ // === 10. 报告 ===
414
+ step('激活报告')
373
415
  const written = report.filter((r) => r.action === 'written')
374
416
  const failed = report.filter(
375
417
  (r) => r.action === 'failed' || r.action === 'permission-denied' || r.action === 'backup-failed',
@@ -378,33 +420,46 @@ async function main() {
378
420
  const dryRunItems = report.filter((r) => r.action === 'dry-run')
379
421
 
380
422
  if (args.dryRun) {
381
- console.log(`[试运行] 将写入 ${dryRunItems.length} 个文件(未真实写入)`)
423
+ plain(`[试运行] 将写入 ${dryRunItems.length} 个文件(未真实写入)`)
382
424
  } else {
383
- console.log(`已写入 ${written.length} 个文件`)
425
+ ok(`已写入 ${written.length} 个文件`)
384
426
  }
385
427
  if (shellOnly.length > 0) {
386
- console.log(`需手动执行的 shell 命令:${shellOnly.length} 项(见上方输出)`)
428
+ info(`需手动执行的 shell 命令:${shellOnly.length} 项(见上方输出)`)
387
429
  }
388
430
  if (failed.length > 0) {
389
- console.warn(`失败 ${failed.length} 项:`)
390
- failed.forEach((f) => console.warn(` - ${f.cli} ${f.path}`))
431
+ warn(`失败 ${failed.length} 项:`)
432
+ failed.forEach((f) => warn(` - ${f.cli} ${f.path}`))
391
433
  }
392
434
 
393
435
  const backups = report.filter((r) => r.backup).map((r) => r.backup)
394
436
  if (backups.length > 0) {
395
- console.log(`备份位置:`)
396
- backups.forEach((b) => console.log(` - ${b}`))
437
+ info('备份位置:')
438
+ backups.forEach((b) => plain(` - ${b}`))
397
439
  }
398
440
 
399
441
  if (bundle.user?.is_new) {
400
442
  console.log()
401
- console.log(`📧 新账号 ${bundle.user.email || ''} 已创建,请查收邮件获取初始密码,登录后尽快修改。`)
443
+ info(`📧 新账号 ${bundle.user.email || ''} 已创建,请查收邮件获取初始密码,登录后尽快修改。`)
444
+ }
445
+
446
+ // === 11. 启动/使用指南(每个激活 CLI)===
447
+ if (!args.dryRun) {
448
+ const guideItems = selectedItems
449
+ .map((item) => item.meta)
450
+ .filter(Boolean)
451
+ .map((meta) => ({
452
+ name: meta.name,
453
+ startCmd: meta.startCmd,
454
+ usage: meta.usage,
455
+ }))
456
+ guide(guideItems)
402
457
  }
403
458
 
404
459
  if (firstPermissionError) {
405
460
  console.log()
406
- console.error('部分文件权限不足,请按提示用 sudo / 管理员 PowerShell 重跑:')
407
- console.error(' sudo npx -y @2api-store/activate')
461
+ fail('部分文件权限不足,请按提示用 sudo / 管理员 PowerShell 重跑:')
462
+ fail(' sudo npx -y @2api-store/activate')
408
463
  return 2
409
464
  }
410
465
 
@@ -417,7 +472,8 @@ main()
417
472
  process.exit(code || 0)
418
473
  })
419
474
  .catch((err) => {
420
- console.error('✗ 未捕获错误:', err)
475
+ fail(`未捕获错误:${err?.message || err}`)
476
+ if (err?.stack) console.error(err.stack)
421
477
  closePrompt()
422
478
  process.exit(1)
423
479
  })
package/lib/detect.mjs CHANGED
@@ -27,7 +27,8 @@ export function detectNode() {
27
27
  }
28
28
 
29
29
  /**
30
- * 已知 CLI 列表(按 spec §5.2 step 1)
30
+ * 已知 CLI 列表(含 codex-ws 共享 codex 二进制)
31
+ * grok 仅检测(无官方 npm CLI 包,不参与自动安装)
31
32
  */
32
33
  const KNOWN_CLIS = ['codex', 'claude', 'gemini', 'grok', 'opencode']
33
34
 
@@ -53,21 +54,63 @@ function findBinary(name, isWindows) {
53
54
  }
54
55
 
55
56
  /**
56
- * 检测已安装的 CLI
57
+ * 查询二进制版本(--version 输出第一行)
58
+ * @param {string} bin 二进制名
57
59
  * @param {boolean} isWindows
58
- * @returns {Record<string, string>} name → path
60
+ * @returns {string|null} 版本字符串(如 "1.2.3"),失败返回 null
61
+ */
62
+ function getVersion(bin, isWindows) {
63
+ // --version 是通用约定;codex/claude/gemini/opencode/grok 都支持
64
+ try {
65
+ const result = spawnSync(bin, ['--version'], {
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ timeout: 5000,
69
+ shell: isWindows, // Windows 可能需要 shell 解析 .cmd
70
+ })
71
+ if (result.status === 0 && result.stdout) {
72
+ const firstLine = result.stdout.trim().split(/\r?\n/)[0]
73
+ // 提取常见版本格式:可能形如 "codex 0.1.2505162003" / "1.2.3" / "claude 2.1.0"
74
+ const match = firstLine.match(/(\d+\.\d+(?:\.\d+)?(?:[-.\w]*)?)/)
75
+ return match ? match[1] : firstLine.slice(0, 40)
76
+ }
77
+ } catch {
78
+ // 二进制存在但 --version 不支持 / 超时 → 不阻塞,返回 null
79
+ }
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * 检测单个二进制是否已安装(含版本)
85
+ * @param {string} bin 二进制名
86
+ * @returns {{installed: boolean, path: string|null, version: string|null}}
87
+ */
88
+ export function detectInstalled(bin) {
89
+ const os = detectOS()
90
+ const path = findBinary(bin, os.isWindows)
91
+ if (!path) return { installed: false, path: null, version: null }
92
+ const version = getVersion(bin, os.isWindows)
93
+ return { installed: true, path, version }
94
+ }
95
+
96
+ /**
97
+ * 检测已安装的 CLI(批量)
98
+ * @param {boolean} isWindows
99
+ * @returns {Record<string, {path: string, version: string|null}>} name → info
59
100
  */
60
101
  export function detectInstalledCLIs(isWindows) {
61
102
  const installed = {}
62
103
  for (const name of KNOWN_CLIS) {
63
104
  const path = findBinary(name, isWindows)
64
- if (path) installed[name] = path
105
+ if (path) {
106
+ installed[name] = { path, version: getVersion(name, isWindows) }
107
+ }
65
108
  }
66
109
  return installed
67
110
  }
68
111
 
69
112
  /**
70
- * 一次性检测全部环境
113
+ * 一次性检测全部环境(含已装 CLI 的版本)
71
114
  */
72
115
  export function detectAll() {
73
116
  const os = detectOS()
@@ -0,0 +1,105 @@
1
+ /**
2
+ * CLI 自动安装模块
3
+ *
4
+ * installCLI(meta) 执行 npm install -g(stdio inherit 展示 npm 进度)
5
+ * ensureInstalled(meta) 检测 + 询问确认 + 按需安装
6
+ *
7
+ * 设计:
8
+ * - 不自动安装(用户必须确认,避免后台改全局环境)
9
+ * - 安装用 spawnSync + stdio inherit(npm 进度直接透传,体验与手动 npm i 一致)
10
+ * - dry-run 模式不真装,只打印「将运行 npm install -g <pkg>」
11
+ * - 未识别包(无 meta / 无 installPackage)→ 返回 skipped,不阻塞主流程
12
+ */
13
+
14
+ import { spawnSync } from 'node:child_process'
15
+ import { detectInstalled } from './detect.mjs'
16
+ import { confirm } from './prompt.mjs'
17
+
18
+ /**
19
+ * 执行 npm install -g
20
+ * @param {{installPackage: string}} meta CLI 元数据
21
+ * @param {{dryRun?: boolean}} opts
22
+ * @returns {{ok: boolean, skipped?: boolean, reason?: string, error?: string}}
23
+ * - ok=true 安装成功(或 dry-run 跳过)
24
+ * - ok=false+error 安装失败(npm exit !=0 / spawn 错误)
25
+ * - skipped=true 因 dryRun 跳过实际安装
26
+ */
27
+ export function installCLI(meta, opts = {}) {
28
+ if (!meta || !meta.installPackage) {
29
+ return { ok: false, skipped: true, reason: 'no-install-package' }
30
+ }
31
+ const pkg = meta.installPackage
32
+ const cmd = `npm install -g ${pkg}`
33
+
34
+ if (opts.dryRun) {
35
+ console.log(` [试运行] 将运行:${cmd}(不真实安装)`)
36
+ return { ok: true, skipped: true, reason: 'dry-run' }
37
+ }
38
+
39
+ console.log(` 正在执行:${cmd}…`)
40
+ try {
41
+ const result = spawnSync('npm', ['install', '-g', pkg], {
42
+ stdio: 'inherit', // npm 进度/输出直接透传到当前终端
43
+ shell: process.platform === 'win32', // Windows 需要 shell 解析 npm.cmd
44
+ })
45
+ if (result.status === 0) {
46
+ return { ok: true }
47
+ }
48
+ const errText = `npm exit ${result.status}`
49
+ return { ok: false, error: errText }
50
+ } catch (err) {
51
+ return { ok: false, error: err.message }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 确保 CLI 已安装:检测 → 未装则询问确认 → 安装
57
+ *
58
+ * @param {{detectBin?: string, installPackage?: string, name?: string, installCmd?: string}} meta CLI 元数据
59
+ * @param {{dryRun?: boolean, autoInstall?: boolean}} opts
60
+ * - dryRun 不真装
61
+ * - autoInstall true=未装时直接装(不问);false/缺省=询问确认后才装
62
+ * @returns {{installed: boolean, skipped?: boolean, reason?: string, version?: string|null, error?: string}}
63
+ * - installed=true 已装(或本次成功安装)
64
+ * - installed=false 未装且用户拒绝安装/安装失败/未知 meta
65
+ */
66
+ export async function ensureInstalled(meta, opts = {}) {
67
+ if (!meta || !meta.installPackage) {
68
+ return { installed: false, skipped: true, reason: 'no-meta' }
69
+ }
70
+ const bin = meta.detectBin || meta.installPackage.split('/').pop()
71
+ const detectResult = detectInstalled(bin)
72
+ if (detectResult.installed) {
73
+ return { installed: true, version: detectResult.version }
74
+ }
75
+
76
+ // 未装:决定是否安装
77
+ const { dryRun, autoInstall } = opts
78
+
79
+ // dry-run:直接走 installCLI 的 dry-run 分支(不问,因为不会真装)
80
+ if (dryRun) {
81
+ const r = installCLI(meta, { dryRun: true })
82
+ return { installed: false, skipped: true, reason: r.reason || 'dry-run' }
83
+ }
84
+
85
+ // 非 dry-run:按 autoInstall 决定是否询问
86
+ let shouldInstall = autoInstall === true
87
+ if (!shouldInstall) {
88
+ shouldInstall = await confirm(
89
+ `未检测到 ${meta.name || bin}(命令 ${bin}),是否现在安装?\n 运行:${meta.installCmd || `npm install -g ${meta.installPackage}`}`,
90
+ { defaultValue: true },
91
+ )
92
+ }
93
+
94
+ if (!shouldInstall) {
95
+ return { installed: false, skipped: true, reason: 'user-declined' }
96
+ }
97
+
98
+ const r = installCLI(meta, { dryRun: false })
99
+ if (r.ok) {
100
+ // 安装后复查(确认二进制可解析)
101
+ const recheck = detectInstalled(bin)
102
+ return { installed: recheck.installed, version: recheck.version, justInstalled: true }
103
+ }
104
+ return { installed: false, error: r.error }
105
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * CLI 元数据(按 client_type 索引)
3
+ *
4
+ * 用于:detect(识别已装/未装)、install(按需 npm i -g)、guide(启动/使用指南)。
5
+ *
6
+ * 字段约定:
7
+ * name 展示名(UI 用)
8
+ * installPackage npm 包名(npm i -g <pkg>)
9
+ * installCmd 完整安装命令字符串(UI 展示用,含 npm i -g)
10
+ * startCmd 启动命令(guide 用)
11
+ * detectBin 检测二进制名(which/where 用)
12
+ * configFiles 典型配置文件路径(仅展示参考,实际写入路径由服务端 configs 驱动)
13
+ * usage 使用提示文案(guide 用)
14
+ * isPrimary true=主 CLI(默认选中);false=独立 CLI(默认不选,如 opencode)
15
+ *
16
+ * npm 包名经 WebSearch 查证(2026-08-08):
17
+ * - @openai/codex 官方包(npm view @openai/codex → 0.147.0)
18
+ * - @anthropic-ai/claude-code 官方包(避开 imposter 包 claude-ai)→ 2.1.226
19
+ * - @google/gemini-cli 官方包 → 0.54.4
20
+ * - opencode-ai SST 官方包(非 @opencode-ai/cli)→ 1.18.15
21
+ */
22
+
23
+ export const CLI_META = {
24
+ codex: {
25
+ name: 'Codex CLI',
26
+ installPackage: '@openai/codex',
27
+ installCmd: 'npm install -g @openai/codex',
28
+ startCmd: 'codex',
29
+ detectBin: 'codex',
30
+ configFiles: ['~/.codex/config.toml', '~/.codex/auth.json'],
31
+ usage: [
32
+ '权限模式(默认每步确认):',
33
+ ' codex --full-auto 全自动(sandbox 内自动批准,推荐)',
34
+ ' codex --yolo 全跳过(无 sandbox 无 approval,仅信任仓库)',
35
+ ' codex --sandbox read-only --ask-for-approval never 只读(最安全)',
36
+ ' codex --help 查看所有选项',
37
+ '项目根放 AGENTS.md 让 Codex 了解你的代码库',
38
+ ].join('\n'),
39
+ isPrimary: true,
40
+ },
41
+ 'codex-ws': {
42
+ // WebSocket 变体:与 codex 同包同二进制,仅服务端配置不同
43
+ name: 'Codex CLI (WebSocket)',
44
+ installPackage: '@openai/codex',
45
+ installCmd: 'npm install -g @openai/codex',
46
+ startCmd: 'codex',
47
+ detectBin: 'codex',
48
+ configFiles: ['~/.codex/config.toml', '~/.codex/auth.json'],
49
+ usage: [
50
+ '权限模式(WebSocket 模式由服务端配置激活,默认每步确认):',
51
+ ' codex --full-auto 全自动(sandbox 内自动批准,推荐)',
52
+ ' codex --yolo 全跳过(无 sandbox 无 approval,仅信任仓库)',
53
+ ' codex --sandbox read-only --ask-for-approval never 只读(最安全)',
54
+ ' codex --help 查看所有选项',
55
+ ].join('\n'),
56
+ isPrimary: true,
57
+ },
58
+ claude: {
59
+ name: 'Claude Code',
60
+ installPackage: '@anthropic-ai/claude-code',
61
+ installCmd: 'npm install -g @anthropic-ai/claude-code',
62
+ startCmd: 'claude',
63
+ detectBin: 'claude',
64
+ configFiles: ['~/.claude/', '~/.claude.json'],
65
+ usage: '运行 `claude` 开始;`claude --help` 查看选项;在项目根放 CLAUDE.md 让 Claude 了解你的代码库',
66
+ isPrimary: true,
67
+ },
68
+ 'gemini-cli': {
69
+ name: 'Gemini CLI',
70
+ installPackage: '@google/gemini-cli',
71
+ installCmd: 'npm install -g @google/gemini-cli',
72
+ startCmd: 'gemini',
73
+ detectBin: 'gemini',
74
+ configFiles: ['~/.gemini/settings.json'],
75
+ usage: '运行 `gemini` 开始;`gemini --help` 查看选项;在项目根放 GEMINI.md 让 Gemini 了解你的代码库',
76
+ isPrimary: true,
77
+ },
78
+ opencode: {
79
+ // 独立 CLI(非主 CLI),默认不选
80
+ name: 'OpenCode',
81
+ installPackage: 'opencode-ai',
82
+ installCmd: 'npm install -g opencode-ai',
83
+ startCmd: 'opencode',
84
+ detectBin: 'opencode',
85
+ configFiles: ['~/.config/opencode/opencode.json', 'opencode.json'],
86
+ usage: '运行 `opencode` 开始;`opencode --help` 查看选项;在项目根放 AGENTS.md 让 OpenCode 了解你的代码库',
87
+ isPrimary: false,
88
+ },
89
+ }
90
+
91
+ /**
92
+ * 按 client_type 查询元数据,未定义返回 null
93
+ * @param {string} clientType
94
+ * @returns {object|null}
95
+ */
96
+ export function getCliMeta(clientType) {
97
+ return CLI_META[clientType] || null
98
+ }
99
+
100
+ /**
101
+ * 判断 client_type 是否默认应选中(主 CLI 默认选,独立 CLI 默认不选)
102
+ * @param {string} clientType
103
+ * @returns {boolean}
104
+ */
105
+ export function isDefaultSelected(clientType) {
106
+ const meta = CLI_META[clientType]
107
+ if (!meta) return true // 未知类型保守全选(向后兼容)
108
+ return meta.isPrimary
109
+ }
package/lib/ui.mjs ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 彩色 UI 输出(chalk 驱动)
3
+ *
4
+ * 颜色规范:
5
+ * banner 粗体青色(品牌色)
6
+ * ✓ ok 绿色(成功)
7
+ * ✗ fail 红色(错误)
8
+ * ⚠ warn 黄色(警告)
9
+ * ℹ info 蓝色(信息)
10
+ * step 粗体青色(步骤标题)
11
+ * guide 绿色(启动/使用指南)
12
+ *
13
+ * chalk 在非 TTY(CI/管道/重定向)/ Windows cmd 自动降级为无色,无需手动处理。
14
+ */
15
+
16
+ import chalk from 'chalk'
17
+
18
+ const PKG_VERSION = '0.3.0'
19
+
20
+ const BANNER = String.raw`
21
+ _ _ _ _ ____ _ ____ _ _
22
+ | | | | | | | | / ___| / \ | __ ) | \ | |
23
+ | |_| | | | | || | _ / _ \ | _ \ | \| |
24
+ | _ | | |_| || |_| |/ ___ \| |_) | | |\ |
25
+ |_| |_| \___/ \____/_/ \_\____/ |_| \_|
26
+ TokenShop CLI 一键激活 v${PKG_VERSION}
27
+ `
28
+
29
+ /** 彩色 banner */
30
+ export function banner() {
31
+ // 去除模板字面量首尾换行,保留 ASCII art 结构
32
+ console.log(chalk.bold.cyan(BANNER.replace(/^\n/, '').trimEnd()))
33
+ }
34
+
35
+ /** 成功(✓ 绿色) */
36
+ export function ok(msg) {
37
+ console.log(chalk.green(`✓ ${msg}`))
38
+ }
39
+
40
+ /** 错误(✗ 红色 → stderr) */
41
+ export function fail(msg) {
42
+ console.error(chalk.red(`✗ ${msg}`))
43
+ }
44
+
45
+ /** 警告(⚠ 黄色) */
46
+ export function warn(msg) {
47
+ console.warn(chalk.yellow(`⚠ ${msg}`))
48
+ }
49
+
50
+ /** 信息(ℹ 蓝色) */
51
+ export function info(msg) {
52
+ console.log(chalk.blue(`ℹ ${msg}`))
53
+ }
54
+
55
+ /**
56
+ * 步骤标题(粗体青色 + 框线分隔)
57
+ * @param {string} title
58
+ */
59
+ export function step(title) {
60
+ console.log()
61
+ console.log(chalk.bold.cyan(`■ ${title}`))
62
+ }
63
+
64
+ /**
65
+ * CLI 启动/使用指南(彩色框)
66
+ * 渲染每个已激活 CLI 的 startCmd + usage
67
+ * @param {Array<{name: string, startCmd: string, usage: string}>} items
68
+ */
69
+ export function guide(items) {
70
+ if (!items || items.length === 0) return
71
+ step('启动 / 使用指南')
72
+ for (const it of items) {
73
+ const head = chalk.bold.green(` ▶ ${it.name}`)
74
+ const start = chalk.green(` 启动:${it.startCmd}`)
75
+ const usage = chalk.dim(it.usage || '')
76
+ console.log(head)
77
+ console.log(start)
78
+ if (usage) console.log(usage)
79
+ console.log()
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 普通纯文本输出(无颜色修饰,用于已经内嵌颜色的字符串或纯文本)
85
+ */
86
+ export function plain(msg) {
87
+ console.log(msg)
88
+ }
89
+
90
+ /** 暴露 chalk 给调用方(复杂场景按需用) */
91
+ export { chalk }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@2api-store/activate",
3
- "version": "0.2.1",
4
- "description": "TokenShop CLI 一键激活:配置本地 codex/claude/gemini/grok/opencode 指向 2api.store 中转",
3
+ "version": "0.3.0",
4
+ "description": "TokenShop CLI 一键激活:配置本地 codex/claude/gemini/grok/opencode 指向 2api.store 中转(彩色输出 + 自动安装 CLI)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "activate": "./activate.mjs"
@@ -32,7 +32,8 @@
32
32
  ],
33
33
  "author": "TokenShop",
34
34
  "dependencies": {
35
- "@inquirer/prompts": "^7.0.0"
35
+ "@inquirer/prompts": "^7.0.0",
36
+ "chalk": "^5.4.0"
36
37
  },
37
38
  "license": "MIT",
38
39
  "publishConfig": {