@2api-store/activate 0.1.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 ADDED
@@ -0,0 +1,103 @@
1
+ # @2api-store/activate
2
+
3
+ TokenShop CLI 一键激活脚本:在任意有 node 环境的终端,一行命令把本地 codex / claude / gemini / grok / opencode 等 CLI 配置为指向 2api.store 中转。
4
+
5
+ ## 快速开始
6
+
7
+ ```bash
8
+ npx @2api-store/activate
9
+ ```
10
+
11
+ 按提示输入 **API Key** 或 **兑换码**,脚本会自动:
12
+
13
+ 1. 检测你的操作系统与已安装的 CLI
14
+ 2. 拉取适配当前系统的配置模板(运行时从服务端获取,脚本不硬编码)
15
+ 3. 备份原配置到 `<path>.bak.<时间戳>`
16
+ 4. 渲染占位符(apiKey / baseUrl / defaultModel)后写入
17
+ 5. 验证 API Key 可访问 `/v1/models`
18
+
19
+ ## 两种模式
20
+
21
+ | 模式 | 输入 | 说明 |
22
+ |------|------|------|
23
+ | **API Key 模式** | `sk-...` | 已有 2api.store 的 API Key,直接配置本地 CLI |
24
+ | **兑换码模式** | 32 字符兑换码 + 邮箱 | 邮箱已有用户则绑定,无对应用户则自动创建账号 + 发激活邮件(含初始密码)→ 消费兑换码 → 创建 API Key 绑定到兑换码指定的分组 |
25
+
26
+ 凭据格式自动识别:`sk-` 前缀走 Key 模式;否则按兑换码处理。
27
+
28
+ ## 命令行选项
29
+
30
+ ```
31
+ npx @2api-store/activate [options]
32
+
33
+ --dry-run, -n 不真写配置,只展示将写入什么
34
+ --platform <name> 只激活指定平台(openai/anthropic/gemini/grok/antigravity)
35
+ --api-base <url> 覆盖 API 端点
36
+ -h, --help 显示帮助
37
+ -v, --version 显示版本
38
+ ```
39
+
40
+ ### 环境变量
41
+
42
+ - `ACTIVATE_API_BASE`:覆盖 API 端点(同 `--api-base`)。本地开发指向 `http://127.0.0.1:5566`。
43
+
44
+ ### --dry-run
45
+
46
+ 只展示将写入哪些文件、内容预览,不真实写入、不创建备份。适合先看一下会发生什么再决定是否正式激活。
47
+
48
+ ```bash
49
+ npx @2api-store/activate --dry-run
50
+ ```
51
+
52
+ ## 权限提示
53
+
54
+ CLI 配置文件位于用户家目录(如 `~/.codex/config.toml`、`~/.claude/`)。脚本先用普通用户权限尝试写入:
55
+
56
+ - **写入成功**:直接完成。
57
+ - **权限不足**(`EACCES` / `EPERM` / `EROFS`):脚本**不会自动 sudo**,而是明确报出目标路径与错误类型,并提示重跑方式:
58
+
59
+ | 系统 | 重跑命令 |
60
+ |------|----------|
61
+ | macOS / Linux | `sudo npx -y @2api-store/activate` |
62
+ | Windows | 用「管理员 PowerShell」运行 `Start-Process powershell -Verb RunAs -ArgumentList "npx,-y,@2api-store/activate"` |
63
+
64
+ ## 备份策略
65
+
66
+ - 写入前自动备份原文件到同目录 `<path>.bak.<ISO时间戳>`(非覆盖,保留多次备份)。
67
+ - 目标文件不存在则跳过备份。
68
+ - 备份失败但文件存在 → 跳过该文件写入,避免无备份覆盖。
69
+
70
+ ## 安全
71
+
72
+ - 凭据输入掩码(`***`)显示。
73
+ - 写入的配置文件权限 `0o600`(仅属主可读写,因含 API Key)。
74
+ - 兑换码模式创建的新账号会发送高熵随机初始密码到邮箱,请登录后尽快修改。
75
+ - 全程不落库明文密码,不自动 sudo。
76
+
77
+ ## 跨平台
78
+
79
+ - **macOS / Linux**:使用 `~` 展开(`$HOME`),选 `os_type=unix` 的配置。
80
+ - **Windows**:使用 `%userprofile%` 展开,选 `os_type=windows` 的配置。
81
+ - `os_type=all` 的配置(如环境变量导出)在两端都可选。
82
+ - 部分"环境变量类"配置(路径为 `Terminal` / `PowerShell`)只打印 shell 命令,让用户手动执行,不写文件。
83
+
84
+ ## 本地开发与测试
85
+
86
+ ```bash
87
+ cd cli-activate
88
+ node --check activate.mjs && for f in lib/*.mjs; do node --check "$f" || exit 1; done
89
+ node test/smoke.mjs # 起本地 mock server 跑全流程(用临时 HOME + --dry-run,不碰真实配置)
90
+ ```
91
+
92
+ ## 退出码
93
+
94
+ | 码 | 含义 |
95
+ |----|------|
96
+ | 0 | 成功 |
97
+ | 1 | 输入错误 / 网络失败 / 致命错误 |
98
+ | 2 | 部分文件权限不足(请按提示用 sudo / 管理员 PowerShell 重跑) |
99
+ | 3 | 其他写入失败 |
100
+
101
+ ## License
102
+
103
+ MIT © TokenShop
package/activate.mjs ADDED
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @2api-store/activate — TokenShop CLI 一键激活
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. 报告:激活清单 / 备份位置 / (新用户)查收邮件
15
+ */
16
+
17
+ import { detectAll } from './lib/detect.mjs'
18
+ import { cliActivate, verifyApiKey } from './lib/api.mjs'
19
+ import { renderContent, expandPath, listModels } from './lib/render.mjs'
20
+ import { backupFile } from './lib/backup.mjs'
21
+ import { writeFileWithPermission, PermissionDeniedError } from './lib/write.mjs'
22
+ import {
23
+ ask,
24
+ askSecret,
25
+ multiSelect,
26
+ select,
27
+ confirm,
28
+ closePrompt,
29
+ } from './lib/prompt.mjs'
30
+
31
+ const VERSION = '0.1.0'
32
+ const DEFAULT_API_BASE = 'https://2api.store'
33
+
34
+ const BANNER = String.raw`
35
+ _ _ _ _ ____ _ ____ _ _
36
+ | | | | | | | | / ___| / \ | __ ) | \ | |
37
+ | |_| | | | | || | _ / _ \ | _ \ | \| |
38
+ | _ | | |_| || |_| |/ ___ \| |_) | | |\ |
39
+ |_| |_| \___/ \____/_/ \_\____/ |_| \_|
40
+ TokenShop CLI 一键激活 v${VERSION}
41
+ `
42
+
43
+ function parseArgs(argv) {
44
+ const args = { dryRun: false, platform: null, _: [] }
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i]
47
+ if (a === '--dry-run' || a === '-n') {
48
+ args.dryRun = true
49
+ } else if (a === '--platform' || a === '-p') {
50
+ args.platform = argv[++i]
51
+ } else if (a === '--api-base') {
52
+ args.apiBase = argv[++i]
53
+ } else if (a === '--help' || a === '-h') {
54
+ args.help = true
55
+ } else if (a === '--version' || a === '-v') {
56
+ args.version = true
57
+ } else if (a.startsWith('--') && a.includes('=')) {
58
+ const [k, v] = a.slice(2).split(/=(.*)/, 2)
59
+ args[k] = v
60
+ } else if (a.startsWith('--')) {
61
+ args[a.replace(/^--/, '')] = argv[++i]
62
+ } else {
63
+ args._.push(a)
64
+ }
65
+ }
66
+ return args
67
+ }
68
+
69
+ function printHelp() {
70
+ console.log(`
71
+ @2api-store/activate v${VERSION} — TokenShop CLI 一键激活
72
+
73
+ 用法:
74
+ npx @2api-store/activate [options]
75
+
76
+ 选项:
77
+ --dry-run, -n 不真写配置,只展示将写入什么
78
+ --platform <name> 只激活指定平台(openai/anthropic/gemini/grok/antigravity)
79
+ --api-base <url> 覆盖 API 端点(默认 $ACTIVATE_API_BASE 或 ${DEFAULT_API_BASE})
80
+ -h, --help 显示帮助
81
+ -v, --version 显示版本
82
+
83
+ 环境变量:
84
+ ACTIVATE_API_BASE 覆盖 API 端点(同 --api-base)
85
+
86
+ 示例:
87
+ npx @2api-store/activate
88
+ npx @2api-store/activate --dry-run
89
+ npx @2api-store/activate --platform openai
90
+ ACTIVATE_API_BASE=http://127.0.0.1:5566 npx @2api-store/activate
91
+
92
+ 退出码:
93
+ 0 成功
94
+ 1 输入错误 / 网络失败 / 致命错误
95
+ 2 部分文件权限不足(请按提示用 sudo / 管理员 PowerShell 重跑)
96
+ 3 其他写入失败
97
+ `.trim())
98
+ }
99
+
100
+ /**
101
+ * 凭据格式分流(spec §5.3)
102
+ * sk- 前缀 → key
103
+ * 32 字符 [a-zA-Z0-9] → redeem
104
+ */
105
+ function classifyCredential(cred) {
106
+ if (!cred) return 'empty'
107
+ if (cred.startsWith('sk-')) return 'key'
108
+ // 兑换码:16-64 字符 [a-zA-Z0-9]
109
+ if (/^[a-zA-Z0-9]{16,64}$/.test(cred)) return 'redeem'
110
+ return 'unknown'
111
+ }
112
+
113
+ async function main() {
114
+ const args = parseArgs(process.argv.slice(2))
115
+ if (args.help) {
116
+ printHelp()
117
+ return 0
118
+ }
119
+ if (args.version) {
120
+ console.log(VERSION)
121
+ return 0
122
+ }
123
+
124
+ console.log(BANNER)
125
+
126
+ // === 1. 检测环境 ===
127
+ const env = detectAll()
128
+ const osLabel = env.os.isMac
129
+ ? 'macOS'
130
+ : env.os.isLinux
131
+ ? 'Linux'
132
+ : env.os.isWindows
133
+ ? 'Windows'
134
+ : env.os.platform
135
+ console.log(`环境:${osLabel} · node v${env.node.version}`)
136
+ if (!env.node.supported) {
137
+ console.error(`✗ 需要 node >= 18,当前 ${env.node.version}`)
138
+ return 1
139
+ }
140
+ const installedNames = Object.keys(env.installed)
141
+ if (installedNames.length > 0) {
142
+ console.log(`已检测到 CLI:${installedNames.join(', ')}`)
143
+ } else {
144
+ console.log('未检测到 codex/claude/gemini/grok/opencode(不影响激活,仅参考)')
145
+ }
146
+
147
+ const apiBase = args.apiBase || process.env.ACTIVATE_API_BASE || DEFAULT_API_BASE
148
+ console.log(`API 端点:${apiBase}`)
149
+ if (args.dryRun) console.log('【试运行模式 --dry-run】不会真写任何文件')
150
+ if (args.platform) console.log(`平台过滤:${args.platform}`)
151
+ console.log()
152
+
153
+ // === 2. 输入凭据 ===
154
+ const credential = (await askSecret('请输入 API Key(sk-...)或兑换码')).trim()
155
+ if (!credential) {
156
+ console.error('✗ 未输入凭据')
157
+ return 1
158
+ }
159
+ const kind = classifyCredential(credential)
160
+ if (kind === 'unknown') {
161
+ console.error('✗ 凭据格式无法识别(应为 sk- 前缀的 API Key 或兑换码)')
162
+ return 1
163
+ }
164
+ console.log(`识别为:${kind === 'key' ? 'API Key' : '兑换码'}模式`)
165
+
166
+ // === 3. 兑换码 → 邮箱 ===
167
+ let email = ''
168
+ if (kind === 'redeem') {
169
+ email = (await ask('请输入邮箱(用于兑换码绑定 / 新账号创建')).trim()
170
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
171
+ console.error('✗ 邮箱格式不正确')
172
+ return 1
173
+ }
174
+ }
175
+
176
+ // === 4. POST /cli/activate ===
177
+ console.log()
178
+ console.log('正在请求激活服务…')
179
+ let bundle
180
+ try {
181
+ bundle = await cliActivate({ credential, email, platform: args.platform, apiBase })
182
+ } catch (err) {
183
+ console.error(`✗ 激活失败:${err.message}`)
184
+ if (err.statusCode) console.error(` HTTP ${err.statusCode}`)
185
+ return 1
186
+ }
187
+
188
+ console.log(`✓ 模式:${bundle.mode}${bundle.user?.is_new ? '(新用户已创建)' : ''}`)
189
+ if (bundle.user?.is_new && bundle.user?.email) {
190
+ console.log(` 新账号:${bundle.user.email}`)
191
+ if (bundle.user.message) console.log(` ${bundle.user.message}`)
192
+ }
193
+ console.log()
194
+
195
+ // === 5. 按 os_type 过滤、展示可用 CLI ===
196
+ let osConfigs = (bundle.configs || []).filter((c) => {
197
+ if (!c) return false
198
+ // 客户端 --platform 二次过滤(防御性,后端已过滤)
199
+ if (args.platform && c.platform !== args.platform) return false
200
+ if (env.os.isWindows) {
201
+ return c.os_type === 'windows' || c.os_type === 'all'
202
+ }
203
+ return c.os_type === 'unix' || c.os_type === 'all'
204
+ })
205
+
206
+ if (osConfigs.length === 0) {
207
+ console.error('✗ 服务端未返回适配当前系统的配置模板')
208
+ return 1
209
+ }
210
+
211
+ // dedupe by platform + client_type
212
+ const seen = new Set()
213
+ const cliItems = []
214
+ for (const c of osConfigs) {
215
+ const key = `${c.platform}/${c.client_type}`
216
+ if (seen.has(key)) continue
217
+ seen.add(key)
218
+ const displayName = c.display_name || c.displayName || `${c.platform} ${c.client_type}`
219
+ cliItems.push({
220
+ label: `${displayName} [${c.platform}/${c.client_type}]`,
221
+ config: c,
222
+ })
223
+ }
224
+
225
+ console.log('可用 CLI:')
226
+ const selectedIdxes = await multiSelect('选择要激活的 CLI(默认全选)', cliItems, {
227
+ defaultSelected: cliItems.map((_, i) => i),
228
+ })
229
+ if (selectedIdxes.length === 0) {
230
+ console.log('未选择任何 CLI,退出。')
231
+ return 0
232
+ }
233
+ const selectedConfigs = selectedIdxes.map((i) => cliItems[i].config)
234
+ console.log()
235
+
236
+ // === 6. 选 defaultModel ===
237
+ const modelItems = listModels(bundle.models || [])
238
+ let defaultModelId = ''
239
+ if (modelItems.length > 0) {
240
+ console.log('可用模型:')
241
+ const selectItems = modelItems.map((m) => ({ label: `${m.label} (${m.id})` }))
242
+ const idx = await select('选择默认模型', selectItems, { defaultIndex: 0 })
243
+ defaultModelId = modelItems[idx].id
244
+ console.log(`已选默认模型:${defaultModelId}`)
245
+ } else {
246
+ console.warn('⚠ 服务端未返回模型列表,{{defaultModel}} 将留空')
247
+ }
248
+ console.log()
249
+
250
+ // 准备渲染值
251
+ const baseUrl = bundle.base_url || ''
252
+ const baseUrlBeta = bundle.base_url_beta || baseUrl
253
+ const renderValues = {
254
+ apiKey: bundle.api_key || '',
255
+ baseUrl,
256
+ apiBase: baseUrl, // alias
257
+ apiBaseBeta: baseUrlBeta,
258
+ antigravityApiBase: `${baseUrl}/antigravity`,
259
+ antigravityApiBaseBeta: `${baseUrlBeta}/antigravity`,
260
+ defaultModel: defaultModelId,
261
+ }
262
+
263
+ // === 7. 每个选中 CLI 的每个 file:备份 → 渲染 → 写入 ===
264
+ const report = []
265
+ let firstPermissionError = null
266
+
267
+ for (const cfg of selectedConfigs) {
268
+ const displayName =
269
+ cfg.display_name || cfg.displayName || `${cfg.platform} ${cfg.client_type}`
270
+ console.log(`── ${displayName} ──`)
271
+ const files = cfg.files || []
272
+ for (const f of files) {
273
+ const rawPath = f.path || ''
274
+ const isShellOnly = rawPath === 'Terminal' || rawPath === 'PowerShell'
275
+ const targetPath = isShellOnly ? rawPath : expandPath(rawPath, { isWindows: env.os.isWindows })
276
+ const rendered = renderContent(f.content || '', renderValues)
277
+
278
+ if (isShellOnly) {
279
+ console.log(` [shell 命令,请手动执行]`)
280
+ console.log(rendered.split('\n').map((l) => ' ' + l).join('\n'))
281
+ report.push({ cli: displayName, path: rawPath, action: 'shell-only' })
282
+ continue
283
+ }
284
+
285
+ console.log(` 目标:${targetPath}`)
286
+
287
+ if (args.dryRun) {
288
+ console.log(` [试运行] 不真实写入`)
289
+ const lines = rendered.split('\n')
290
+ const preview = lines.slice(0, 8).join('\n')
291
+ console.log(' 内容预览:')
292
+ console.log(preview.split('\n').map((l) => ' ' + l).join('\n'))
293
+ if (lines.length > 8) console.log(` ...(共 ${lines.length} 行)`)
294
+ report.push({ cli: displayName, path: targetPath, action: 'dry-run' })
295
+ console.log()
296
+ continue
297
+ }
298
+
299
+ // 备份
300
+ let backupPath = null
301
+ try {
302
+ backupPath = backupFile(targetPath)
303
+ if (backupPath) console.log(` 备份:${backupPath}`)
304
+ } catch (err) {
305
+ console.error(` ✗ 备份失败:${err.message}(跳过此文件避免无备份覆盖)`)
306
+ report.push({ cli: displayName, path: targetPath, action: 'backup-failed', error: err.message })
307
+ continue
308
+ }
309
+
310
+ // 写入(两阶段权限)
311
+ try {
312
+ writeFileWithPermission(targetPath, rendered, { isWindows: env.os.isWindows })
313
+ console.log(` ✓ 已写入`)
314
+ report.push({ cli: displayName, path: targetPath, action: 'written', backup: backupPath })
315
+ } catch (err) {
316
+ if (err instanceof PermissionDeniedError) {
317
+ if (!firstPermissionError) firstPermissionError = err
318
+ console.error(` ✗ ${err.message}`)
319
+ report.push({
320
+ cli: displayName,
321
+ path: targetPath,
322
+ action: 'permission-denied',
323
+ error: err.message,
324
+ })
325
+ } else {
326
+ console.error(` ✗ 写入失败:${err.message}`)
327
+ report.push({ cli: displayName, path: targetPath, action: 'failed', error: err.message })
328
+ }
329
+ }
330
+ }
331
+ console.log()
332
+ }
333
+
334
+ closePrompt()
335
+
336
+ // === 8. 验证 API Key(非 dry-run) ===
337
+ if (!args.dryRun && bundle.api_key && bundle.base_url) {
338
+ console.log('验证 API Key…')
339
+ const v = await verifyApiKey({ apiKey: bundle.api_key, baseUrl: bundle.base_url })
340
+ if (v.ok) {
341
+ console.log(`✓ API Key 验证通过(GET /v1/models → 200)`)
342
+ } else {
343
+ console.warn(
344
+ `⚠ 验证未通过:${v.error || `HTTP ${v.statusCode}`}(配置已写入,请手动检查)`,
345
+ )
346
+ }
347
+ console.log()
348
+ }
349
+
350
+ // === 9. 报告 ===
351
+ console.log('══════════ 激活报告 ══════════')
352
+ const written = report.filter((r) => r.action === 'written')
353
+ const failed = report.filter(
354
+ (r) => r.action === 'failed' || r.action === 'permission-denied' || r.action === 'backup-failed',
355
+ )
356
+ const shellOnly = report.filter((r) => r.action === 'shell-only')
357
+ const dryRunItems = report.filter((r) => r.action === 'dry-run')
358
+
359
+ if (args.dryRun) {
360
+ console.log(`[试运行] 将写入 ${dryRunItems.length} 个文件(未真实写入)`)
361
+ } else {
362
+ console.log(`已写入 ${written.length} 个文件`)
363
+ }
364
+ if (shellOnly.length > 0) {
365
+ console.log(`需手动执行的 shell 命令:${shellOnly.length} 项(见上方输出)`)
366
+ }
367
+ if (failed.length > 0) {
368
+ console.warn(`失败 ${failed.length} 项:`)
369
+ failed.forEach((f) => console.warn(` - ${f.cli} ${f.path}`))
370
+ }
371
+
372
+ const backups = report.filter((r) => r.backup).map((r) => r.backup)
373
+ if (backups.length > 0) {
374
+ console.log(`备份位置:`)
375
+ backups.forEach((b) => console.log(` - ${b}`))
376
+ }
377
+
378
+ if (bundle.user?.is_new) {
379
+ console.log()
380
+ console.log(`📧 新账号 ${bundle.user.email || ''} 已创建,请查收邮件获取初始密码,登录后尽快修改。`)
381
+ }
382
+
383
+ if (firstPermissionError) {
384
+ console.log()
385
+ console.error('部分文件权限不足,请按提示用 sudo / 管理员 PowerShell 重跑:')
386
+ console.error(' sudo npx -y @2api-store/activate')
387
+ return 2
388
+ }
389
+
390
+ return failed.length > 0 ? 3 : 0
391
+ }
392
+
393
+ main()
394
+ .then((code) => {
395
+ closePrompt()
396
+ process.exit(code || 0)
397
+ })
398
+ .catch((err) => {
399
+ console.error('✗ 未捕获错误:', err)
400
+ closePrompt()
401
+ process.exit(1)
402
+ })
package/lib/api.mjs ADDED
@@ -0,0 +1,119 @@
1
+ import https from 'node:https'
2
+ import http from 'node:http'
3
+
4
+ const USER_AGENT = '@2api-store/activate/0.1.0'
5
+ const DEFAULT_TIMEOUT_MS = 30000
6
+
7
+ /**
8
+ * 发送 POST /api/v1/cli/activate 请求
9
+ * @param {{credential: string, email?: string, platform?: string, apiBase: string}} opts
10
+ * @returns {Promise<object>} 解析后的 JSON 响应
11
+ */
12
+ export function cliActivate({ credential, email, platform, apiBase }) {
13
+ return new Promise((resolve, reject) => {
14
+ let url
15
+ try {
16
+ url = new URL('/api/v1/cli/activate', apiBase)
17
+ } catch (err) {
18
+ reject(new Error(`无效的 apiBase:${apiBase}(${err.message})`))
19
+ return
20
+ }
21
+
22
+ const lib = url.protocol === 'https:' ? https : http
23
+ const payload = {
24
+ credential,
25
+ ...(email ? { email } : {}),
26
+ ...(platform ? { platform } : {}),
27
+ }
28
+ const body = JSON.stringify(payload)
29
+
30
+ const req = lib.request(
31
+ url,
32
+ {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ 'Content-Length': Buffer.byteLength(body),
37
+ 'User-Agent': USER_AGENT,
38
+ },
39
+ timeout: DEFAULT_TIMEOUT_MS,
40
+ },
41
+ (res) => {
42
+ const chunks = []
43
+ res.on('data', (c) => chunks.push(c))
44
+ res.on('end', () => {
45
+ const text = Buffer.concat(chunks).toString('utf8')
46
+ let json
47
+ try {
48
+ json = text ? JSON.parse(text) : {}
49
+ } catch {
50
+ const err = new Error(`响应非 JSON(HTTP ${res.statusCode}):${text.slice(0, 200)}`)
51
+ err.statusCode = res.statusCode
52
+ err.raw = text
53
+ reject(err)
54
+ return
55
+ }
56
+ if (res.statusCode >= 200 && res.statusCode < 300) {
57
+ resolve(json)
58
+ } else {
59
+ const msg = json?.error || json?.message || `HTTP ${res.statusCode}`
60
+ const err = new Error(msg)
61
+ err.statusCode = res.statusCode
62
+ err.body = json
63
+ reject(err)
64
+ }
65
+ })
66
+ },
67
+ )
68
+
69
+ req.on('timeout', () => {
70
+ req.destroy(new Error(`请求超时(${DEFAULT_TIMEOUT_MS / 1000}s)— ${url.href}`))
71
+ })
72
+ req.on('error', reject)
73
+ req.write(body)
74
+ req.end()
75
+ })
76
+ }
77
+
78
+ /**
79
+ * 验证 API Key:GET {baseUrl}/v1/models(Bearer api_key),期望 200
80
+ * @param {{apiKey: string, baseUrl: string}} opts
81
+ * @returns {Promise<{ok: boolean, statusCode?: number, error?: string}>}
82
+ */
83
+ export function verifyApiKey({ apiKey, baseUrl }) {
84
+ return new Promise((resolve) => {
85
+ let url
86
+ try {
87
+ url = new URL('/v1/models', baseUrl)
88
+ } catch {
89
+ resolve({ ok: false, error: `无效的 baseUrl:${baseUrl}` })
90
+ return
91
+ }
92
+ const lib = url.protocol === 'https:' ? https : http
93
+ const req = lib.request(
94
+ url,
95
+ {
96
+ method: 'GET',
97
+ headers: {
98
+ Authorization: `Bearer ${apiKey}`,
99
+ 'User-Agent': USER_AGENT,
100
+ },
101
+ timeout: 15000,
102
+ },
103
+ (res) => {
104
+ const chunks = []
105
+ res.on('data', (c) => chunks.push(c))
106
+ res.on('end', () => {
107
+ const ok = res.statusCode >= 200 && res.statusCode < 300
108
+ resolve({ ok, statusCode: res.statusCode })
109
+ })
110
+ },
111
+ )
112
+ req.on('timeout', () => {
113
+ req.destroy()
114
+ resolve({ ok: false, error: '验证请求超时(15s)' })
115
+ })
116
+ req.on('error', (err) => resolve({ ok: false, error: err.message }))
117
+ req.end()
118
+ })
119
+ }
package/lib/backup.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs'
2
+
3
+ /**
4
+ * 备份目标文件到同目录 .bak.<ISO时间戳>(非覆盖,保留多次)。
5
+ *
6
+ * 行为(spec §5.4):
7
+ * - 文件不存在 → 返回 null(跳过备份)
8
+ * - 文件存在且备份成功 → 返回备份路径
9
+ * - 文件存在但备份失败 → 抛错(调用方应中止写入,避免无备份覆盖)
10
+ *
11
+ * @param {string} path 目标文件绝对路径
12
+ * @returns {string|null} 备份路径,或 null(文件不存在)
13
+ * @throws {Error} 文件存在但 copyFileSync 失败
14
+ */
15
+ export function backupFile(path) {
16
+ let exists = false
17
+ try {
18
+ exists = fs.existsSync(path)
19
+ } catch {
20
+ // 无法检查存在性(权限等)— 视为不存在,best effort
21
+ return null
22
+ }
23
+ if (!exists) return null
24
+
25
+ // ISO 时间戳,文件名安全化(: 和 . 替换为 -)
26
+ const ts = new Date().toISOString().replace(/[:.]/g, '-')
27
+ const backupPath = `${path}.bak.${ts}`
28
+
29
+ // copyFileSync 失败会抛出 — 调用方知道文件存在且备份失败
30
+ fs.copyFileSync(path, backupPath)
31
+ return backupPath
32
+ }
package/lib/detect.mjs ADDED
@@ -0,0 +1,77 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ /**
4
+ * 检测操作系统
5
+ */
6
+ export function detectOS() {
7
+ const platform = process.platform
8
+ const isWindows = platform === 'win32'
9
+ const isMac = platform === 'darwin'
10
+ const isLinux = platform === 'linux'
11
+ return {
12
+ platform,
13
+ isWindows,
14
+ isMac,
15
+ isLinux,
16
+ osType: isWindows ? 'windows' : 'unix',
17
+ }
18
+ }
19
+
20
+ /**
21
+ * 检测 node 版本
22
+ */
23
+ export function detectNode() {
24
+ const version = process.versions.node
25
+ const major = parseInt(version.split('.')[0], 10)
26
+ return { version, major, supported: major >= 18 }
27
+ }
28
+
29
+ /**
30
+ * 已知 CLI 列表(按 spec §5.2 step 1)
31
+ */
32
+ const KNOWN_CLIS = ['codex', 'claude', 'gemini', 'grok', 'opencode']
33
+
34
+ /**
35
+ * 用 which/where 查找二进制路径
36
+ */
37
+ function findBinary(name, isWindows) {
38
+ const cmd = isWindows ? 'where' : 'which'
39
+ try {
40
+ const result = spawnSync(cmd, [name], {
41
+ encoding: 'utf8',
42
+ stdio: ['ignore', 'pipe', 'ignore'],
43
+ timeout: 3000,
44
+ })
45
+ if (result.status === 0 && result.stdout) {
46
+ const first = result.stdout.trim().split(/\r?\n/)[0]
47
+ return first || null
48
+ }
49
+ } catch {
50
+ // spawn 失败(如命令不存在),忽略
51
+ }
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * 检测已安装的 CLI
57
+ * @param {boolean} isWindows
58
+ * @returns {Record<string, string>} name → path
59
+ */
60
+ export function detectInstalledCLIs(isWindows) {
61
+ const installed = {}
62
+ for (const name of KNOWN_CLIS) {
63
+ const path = findBinary(name, isWindows)
64
+ if (path) installed[name] = path
65
+ }
66
+ return installed
67
+ }
68
+
69
+ /**
70
+ * 一次性检测全部环境
71
+ */
72
+ export function detectAll() {
73
+ const os = detectOS()
74
+ const node = detectNode()
75
+ const installed = detectInstalledCLIs(os.isWindows)
76
+ return { os, node, installed }
77
+ }
package/lib/prompt.mjs ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * 提示输入模块。
3
+ *
4
+ * 两种模式:
5
+ * - TTY(交互式):readline.question,支持 askSecret 掩码。
6
+ * - 非 TTY(管道/测试):一次性把 stdin 读入按行缓冲,从队列消费。
7
+ * 避免 HTTP/异步操作期间 readline 把数据"吸走"导致后续 question 拿不到输入。
8
+ */
9
+
10
+ import readline from 'node:readline'
11
+
12
+ let _rl = null
13
+ let _lineQueue = null // string[] | null(仅非 TTY 时使用)
14
+ let _lineQueuePromise = null
15
+
16
+ function isInteractive() {
17
+ return process.stdin.isTTY === true
18
+ }
19
+
20
+ /**
21
+ * 非交互模式:把 stdin 全量读入,按 \n 切成行队列。
22
+ * 后续 ask 从队列取;队列空时返回 undefined(视作空字符串)。
23
+ */
24
+ function ensureLineQueue() {
25
+ if (_lineQueue) return _lineQueuePromise
26
+ _lineQueue = []
27
+ _lineQueuePromise = new Promise((resolve) => {
28
+ let data = ''
29
+ process.stdin.setEncoding('utf8')
30
+ process.stdin.on('data', (c) => {
31
+ data += c
32
+ })
33
+ process.stdin.on('end', () => {
34
+ // 按 \n 切,去掉末尾空行
35
+ const lines = data.split(/\r?\n/)
36
+ // 如果末尾有 \n,split 会产生一个空字符串元素;保留中间空行(用户回车默认)
37
+ // 去掉最后一个空元素(来自末尾 \n)
38
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
39
+ _lineQueue.push(...lines)
40
+ resolve()
41
+ })
42
+ // 如果 stdin 已结束(极端情况),也 resolve
43
+ if (process.stdin.destroyed) resolve()
44
+ })
45
+ return _lineQueuePromise
46
+ }
47
+
48
+ function getRl() {
49
+ if (!_rl || _rl.closed) {
50
+ _rl = readline.createInterface({
51
+ input: process.stdin,
52
+ output: process.stdout,
53
+ })
54
+ }
55
+ return _rl
56
+ }
57
+
58
+ /**
59
+ * 关闭提示接口(退出前调用)
60
+ */
61
+ export function closePrompt() {
62
+ if (_rl && !_rl.closed) {
63
+ _rl.close()
64
+ }
65
+ _rl = null
66
+ }
67
+
68
+ /**
69
+ * 普通文本输入
70
+ * @param {string} question
71
+ * @param {{defaultValue?: string}} [opts]
72
+ * @returns {Promise<string>}
73
+ */
74
+ export async function ask(question, { defaultValue } = {}) {
75
+ const suffix = defaultValue ? ` [${defaultValue}]` : ''
76
+ // 非 TTY:从行队列取
77
+ if (!isInteractive()) {
78
+ await ensureLineQueue()
79
+ const line = _lineQueue.shift() ?? ''
80
+ // 模拟 prompt 输出(可读性)
81
+ process.stdout.write(`${question}${suffix}: ${line}\n`)
82
+ const v = line.trim()
83
+ return v || (defaultValue ?? '')
84
+ }
85
+ // TTY:readline
86
+ const rl = getRl()
87
+ return new Promise((resolve) => {
88
+ rl.question(`${question}${suffix}: `, (answer) => {
89
+ const v = (answer || '').trim()
90
+ resolve(v || (defaultValue ?? ''))
91
+ })
92
+ })
93
+ }
94
+
95
+ /**
96
+ * 隐藏输入(API Key / 敏感凭据)
97
+ * 非 TTY 退化为 ask(可见输入),便于测试。
98
+ * @param {string} question
99
+ * @returns {Promise<string>}
100
+ */
101
+ export async function askSecret(question) {
102
+ // 非 TTY(含管道)→ ask
103
+ if (!isInteractive()) {
104
+ return ask(question)
105
+ }
106
+ // TTY:先关 rl(它独占 stdin),切原始模式逐字符读取
107
+ if (_rl && !_rl.closed) _rl.close()
108
+ _rl = null
109
+
110
+ return new Promise((resolve) => {
111
+ process.stdout.write(`${question}: `)
112
+ let result = ''
113
+ let resolved = false
114
+
115
+ const cleanup = () => {
116
+ if (resolved) return
117
+ resolved = true
118
+ process.stdin.removeListener('data', onData)
119
+ try {
120
+ process.stdin.setRawMode(false)
121
+ } catch {
122
+ // ignore
123
+ }
124
+ process.stdin.pause()
125
+ process.stdout.write('\n')
126
+ resolve(result)
127
+ }
128
+
129
+ const onData = (buf) => {
130
+ for (let i = 0; i < buf.length; i++) {
131
+ const byte = buf[i]
132
+ if (byte === 13 || byte === 10) {
133
+ // Enter
134
+ cleanup()
135
+ return
136
+ } else if (byte === 3 || byte === 4) {
137
+ // Ctrl+C / Ctrl+D
138
+ cleanup()
139
+ return
140
+ } else if (byte === 127 || byte === 8) {
141
+ // Backspace / Ctrl+H
142
+ if (result.length > 0) {
143
+ result = result.slice(0, -1)
144
+ process.stdout.write('\b \b')
145
+ }
146
+ } else if (byte >= 32 && byte < 127) {
147
+ result += String.fromCharCode(byte)
148
+ process.stdout.write('*')
149
+ }
150
+ }
151
+ }
152
+
153
+ try {
154
+ process.stdin.setRawMode(true)
155
+ } catch {
156
+ // 无法 raw mode — 退化为可见输入
157
+ process.stdout.write('\n')
158
+ resolve('')
159
+ return
160
+ }
161
+ process.stdin.resume()
162
+ process.stdin.on('data', onData)
163
+ })
164
+ }
165
+
166
+ /**
167
+ * 多选(空格/逗号分隔序号)。
168
+ * - 输入空 → defaultSelected
169
+ * - 输入 a/all → 全选
170
+ * - 输入 n/none → 全不选
171
+ * - 输入 1,3 5 → 选 [0, 2, 4]
172
+ *
173
+ * @param {string} question
174
+ * @param {Array<{label: string}>} items
175
+ * @param {{defaultSelected?: number[]}} [opts]
176
+ * @returns {Promise<number[]>} 选中的索引数组
177
+ */
178
+ export async function multiSelect(question, items, { defaultSelected = [] } = {}) {
179
+ if (items.length === 0) return []
180
+ console.log(question)
181
+ items.forEach((item, i) => {
182
+ const selected = defaultSelected.includes(i)
183
+ console.log(` [${selected ? 'x' : ' '}] ${i + 1}. ${item.label}`)
184
+ })
185
+ console.log(' (输入序号,逗号/空格分隔;回车=默认;a=全选;n=全不选)')
186
+
187
+ const defaultStr = defaultSelected.map((i) => i + 1).join(',')
188
+ const answer = await ask('选择', { defaultValue: defaultStr })
189
+ const trimmed = answer.trim().toLowerCase()
190
+
191
+ if (trimmed === 'a' || trimmed === 'all') {
192
+ return items.map((_, i) => i)
193
+ }
194
+ if (trimmed === 'n' || trimmed === 'none') {
195
+ return []
196
+ }
197
+
198
+ const nums = answer
199
+ .split(/[,\s]+/)
200
+ .map((s) => parseInt(s, 10))
201
+ .filter((n) => !isNaN(n) && n >= 1 && n <= items.length)
202
+ .map((n) => n - 1)
203
+ // 去重
204
+ return [...new Set(nums)]
205
+ }
206
+
207
+ /**
208
+ * 单选
209
+ * @param {string} question
210
+ * @param {Array<{label: string}>} items
211
+ * @param {{defaultIndex?: number}} [opts]
212
+ * @returns {Promise<number>} 选中的索引(items.length===1 时直接返回 0)
213
+ */
214
+ export async function select(question, items, { defaultIndex = 0 } = {}) {
215
+ if (items.length === 0) return -1
216
+ if (items.length === 1) return 0
217
+ console.log(question)
218
+ items.forEach((item, i) => {
219
+ const marker = i === defaultIndex ? ' (默认)' : ''
220
+ console.log(` ${i + 1}. ${item.label}${marker}`)
221
+ })
222
+ const answer = await ask('选择序号', { defaultValue: String(defaultIndex + 1) })
223
+ const n = parseInt(answer, 10)
224
+ if (isNaN(n) || n < 1 || n > items.length) return defaultIndex
225
+ return n - 1
226
+ }
227
+
228
+ /**
229
+ * 是非确认
230
+ * @param {string} question
231
+ * @param {{defaultValue?: boolean}} [opts]
232
+ * @returns {Promise<boolean>}
233
+ */
234
+ export async function confirm(question, { defaultValue = true } = {}) {
235
+ const hint = defaultValue ? 'Y/n' : 'y/N'
236
+ const answer = await ask(`${question} (${hint})`, {
237
+ defaultValue: defaultValue ? 'y' : 'n',
238
+ })
239
+ const v = answer.trim().toLowerCase()
240
+ if (!v) return defaultValue
241
+ return v === 'y' || v === 'yes'
242
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 占位符替换工具
3
+ *
4
+ * 对照 frontend/src/utils/clientConfigDefaults.ts,已知占位符:
5
+ * {{apiKey}} — API Key
6
+ * {{baseUrl}} — 主基址(如 https://2api.store)
7
+ * {{apiBase}} — 等价于 baseUrl(OpenCode 用此名)
8
+ * {{apiBaseBeta}} — beta 基址(OpenCode Gemini 用)
9
+ * {{antigravityApiBase}} — antigravity 基址(= baseUrl + /antigravity)
10
+ * {{antigravityApiBaseBeta}} — antigravity beta 基址
11
+ * {{defaultModel}} — 用户选择的默认模型 id(由脚本注入,后端保留占位符)
12
+ *
13
+ * 后端预注入 apiKey/baseUrl(spec §4.3 step 4),脚本主要注入 defaultModel。
14
+ * 为防御性处理(后端漏注入或返回原始模板),本函数替换全部已知占位符。
15
+ */
16
+
17
+ export const PLACEHOLDERS = [
18
+ 'apiKey',
19
+ 'baseUrl',
20
+ 'apiBase',
21
+ 'apiBaseBeta',
22
+ 'antigravityApiBase',
23
+ 'antigravityApiBaseBeta',
24
+ 'defaultModel',
25
+ ]
26
+
27
+ /**
28
+ * 渲染内容:替换所有已知占位符
29
+ * @param {string} content
30
+ * @param {Record<string, string>} values
31
+ * @returns {string}
32
+ */
33
+ export function renderContent(content, values) {
34
+ if (!content) return ''
35
+ let out = content
36
+ for (const key of PLACEHOLDERS) {
37
+ const v = values[key]
38
+ if (v !== undefined && v !== null) {
39
+ // 字面替换,避免 RegExp 特殊字符问题
40
+ out = out.split(`{{${key}}}`).join(String(v))
41
+ }
42
+ }
43
+ return out
44
+ }
45
+
46
+ /**
47
+ * 展开路径占位符
48
+ * unix: ~/foo → $HOME/foo
49
+ * windows: %userprofile%\foo → %USERPROFILE%\foo
50
+ * @param {string} p 原始路径
51
+ * @param {{isWindows: boolean}} opts
52
+ * @returns {string}
53
+ */
54
+ export function expandPath(p, { isWindows } = {}) {
55
+ if (!p) return p
56
+ if (isWindows) {
57
+ const userProfile = process.env.USERPROFILE || ''
58
+ return p.replace(/^%userprofile%/i, userProfile).replace(/^~/, userProfile)
59
+ }
60
+ const home = process.env.HOME || ''
61
+ if (p === '~') return home
62
+ if (p.startsWith('~/')) return home + p.slice(1)
63
+ return p
64
+ }
65
+
66
+ /**
67
+ * 将 models 数组规整为 {id, label} 列表,供选择展示用
68
+ * @param {Array<{id?: string, name?: string}|string>} models
69
+ * @returns {Array<{index: number, id: string, label: string, raw: any}>}
70
+ */
71
+ export function listModels(models) {
72
+ if (!Array.isArray(models) || models.length === 0) return []
73
+ return models.map((m, i) => {
74
+ if (typeof m === 'string') {
75
+ return { index: i, id: m, label: m, raw: m }
76
+ }
77
+ const id = m.id || m.name || String(m)
78
+ const label = m.name || m.id || id
79
+ return { index: i, id, label, raw: m }
80
+ })
81
+ }
package/lib/write.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * 权限相关错误码(EACCES/EPERM/EROFS)
6
+ */
7
+ const PERM_CODES = new Set(['EACCES', 'EPERM', 'EROFS'])
8
+
9
+ /**
10
+ * 权限不足错误(含明确的重跑提示,绝不自动 sudo)
11
+ */
12
+ export class PermissionDeniedError extends Error {
13
+ constructor(targetPath, cause, isWindows) {
14
+ const tip = isWindows
15
+ ? '请用「管理员 PowerShell」重跑:\n Start-Process powershell -Verb RunAs -ArgumentList "npx,-y,@2api-store/activate"'
16
+ : '请用 sudo 重跑:\n sudo npx -y @2api-store/activate'
17
+ super(`写入失败:${targetPath}(${cause.code || cause.message})\n ${tip}`)
18
+ this.name = 'PermissionDeniedError'
19
+ this.code = cause.code
20
+ this.path = targetPath
21
+ this.cause = cause
22
+ }
23
+ }
24
+
25
+ /**
26
+ * 两阶段写入(spec §5.5):
27
+ * 1. 先 fs.mkdirSync(recursive) 确保父目录
28
+ * 2. fs.writeFile 试用户权限
29
+ * 3. 失败(EACCES/EPERM/EROFS)→ 抛 PermissionDeniedError(含提示,绝不自动 sudo)
30
+ *
31
+ * 文件权限 0o600(仅属主读写,因含 API Key)
32
+ *
33
+ * @param {string} targetPath 目标绝对路径
34
+ * @param {string} content 写入内容
35
+ * @param {{isWindows?: boolean}} opts
36
+ */
37
+ export function writeFileWithPermission(targetPath, content, { isWindows = false } = {}) {
38
+ const dir = path.dirname(targetPath)
39
+ try {
40
+ fs.mkdirSync(dir, { recursive: true })
41
+ } catch (err) {
42
+ if (PERM_CODES.has(err.code)) {
43
+ throw new PermissionDeniedError(targetPath, err, isWindows)
44
+ }
45
+ throw err
46
+ }
47
+
48
+ try {
49
+ fs.writeFileSync(targetPath, content, { encoding: 'utf8', mode: 0o600 })
50
+ } catch (err) {
51
+ if (PERM_CODES.has(err.code)) {
52
+ throw new PermissionDeniedError(targetPath, err, isWindows)
53
+ }
54
+ throw err
55
+ }
56
+ return targetPath
57
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@2api-store/activate",
3
+ "version": "0.1.0",
4
+ "description": "TokenShop CLI 一键激活:配置本地 codex/claude/gemini/grok/opencode 指向 2api.store 中转",
5
+ "type": "module",
6
+ "bin": {
7
+ "activate": "activate.mjs"
8
+ },
9
+ "main": "./activate.mjs",
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "scripts": {
14
+ "check": "node --check activate.mjs && for f in lib/*.mjs; do node --check \"$f\" || exit 1; done",
15
+ "smoke": "node test/smoke.mjs"
16
+ },
17
+ "files": [
18
+ "activate.mjs",
19
+ "lib/",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "cli",
24
+ "activate",
25
+ "2api",
26
+ "tokenshop",
27
+ "codex",
28
+ "claude",
29
+ "gemini",
30
+ "grok",
31
+ "opencode"
32
+ ],
33
+ "author": "TokenShop",
34
+ "license": "MIT",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }