@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 +103 -0
- package/activate.mjs +402 -0
- package/lib/api.mjs +119 -0
- package/lib/backup.mjs +32 -0
- package/lib/detect.mjs +77 -0
- package/lib/prompt.mjs +242 -0
- package/lib/render.mjs +81 -0
- package/lib/write.mjs +57 -0
- package/package.json +38 -0
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
|
+
}
|