@icode-js/icode 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +287 -24
- package/bin/icode.js +10 -0
- package/package.json +3 -3
- package/src/cli.js +175 -8
- package/src/commands/checkout.js +73 -6
- package/src/commands/clean.js +47 -7
- package/src/commands/config-ux.js +292 -0
- package/src/commands/config.js +199 -48
- package/src/commands/continue.js +104 -0
- package/src/commands/doctor.js +15 -29
- package/src/commands/help.js +54 -34
- package/src/commands/history.js +147 -0
- package/src/commands/info.js +39 -4
- package/src/commands/log.js +142 -0
- package/src/commands/migrate.js +19 -12
- package/src/commands/open.js +138 -0
- package/src/commands/pr.js +219 -0
- package/src/commands/push.js +62 -22
- package/src/commands/ready.js +18 -48
- package/src/commands/release.js +112 -0
- package/src/commands/rescue.js +164 -0
- package/src/commands/save.js +52 -11
- package/src/commands/stash.js +210 -0
- package/src/commands/sw.js +89 -0
- package/src/commands/sync.js +19 -2
- package/src/commands/tag.js +40 -1
- package/src/commands/undo.js +9 -3
- package/src/core/ai/diff-range.js +1 -1
- package/src/core/cli/completion.js +237 -1
- package/src/core/cli/output.js +46 -0
- package/src/core/cli/render.js +121 -0
- package/src/core/commands/shell.js +9 -1
- package/src/core/config/repo-config.js +93 -0
- package/src/core/config/store.js +60 -10
- package/src/core/git/autostash.js +46 -0
- package/src/core/git/backup.js +170 -0
- package/src/core/git/conflicts.js +58 -0
- package/src/core/git/context.js +72 -5
- package/src/core/git/file-guard.js +193 -0
- package/src/core/git/operation-state.js +63 -0
- package/src/core/git/operations.js +32 -0
- package/src/core/git/semver.js +194 -0
- package/src/core/git/service.js +454 -24
- package/src/core/platform/http.js +53 -0
- package/src/core/platform/index.js +165 -0
- package/src/core/platform/providers.js +454 -0
- package/src/core/platform/remote-url.js +92 -0
- package/src/core/remote-merge/client.js +33 -3
- package/src/core/tools/browser.js +28 -0
- package/src/core/tools/commit-prompt.js +137 -0
- package/src/core/tools/interactive.js +272 -40
- package/src/core/tools/logger.js +33 -23
- package/src/core/tools/theme.js +228 -0
- package/src/workflows/ai-commit-workflow.js +52 -20
- package/src/workflows/checkout-workflow.js +24 -10
- package/src/workflows/clean-workflow.js +135 -83
- package/src/workflows/continue-workflow.js +233 -0
- package/src/workflows/doctor-workflow.js +10 -7
- package/src/workflows/history-workflow.js +211 -0
- package/src/workflows/migrate-workflow.js +39 -14
- package/src/workflows/pr-workflow.js +252 -0
- package/src/workflows/push/commit-step.js +123 -0
- package/src/workflows/push/local-merge-step.js +213 -0
- package/src/workflows/push/plan.js +63 -0
- package/src/workflows/push/remote-merge-step.js +224 -0
- package/src/workflows/push-workflow.js +185 -492
- package/src/workflows/ready-workflow.js +14 -10
- package/src/workflows/release-workflow.js +189 -0
- package/src/workflows/rescue-workflow.js +137 -0
- package/src/workflows/save-workflow.js +106 -41
- package/src/workflows/switch-workflow.js +171 -0
- package/src/workflows/sync-workflow.js +143 -44
- package/src/workflows/tag-workflow.js +103 -17
- package/src/workflows/undo-workflow.js +113 -34
- package/src/workflows/rollback-workflow.js +0 -99
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { upsertAiProfile, useAiProfile } from '../core/ai/config.js'
|
|
5
|
+
import { findRepoConfigFile, readRepoConfig } from '../core/config/repo-config.js'
|
|
6
|
+
import { getConfigFilePath, getRepoPolicy, readConfig, setRepoPolicy, setValue, writeConfig } from '../core/config/store.js'
|
|
7
|
+
import { IcodeError } from '../core/errors.js'
|
|
8
|
+
import { resolveGitContext } from '../core/git/context.js'
|
|
9
|
+
import { resolvePlatformToken, SUPPORTED_PLATFORM_NAMES } from '../core/platform/index.js'
|
|
10
|
+
import { chooseOne, confirm, input, isInteractiveTerminal } from '../core/tools/interactive.js'
|
|
11
|
+
import { logger } from '../core/tools/logger.js'
|
|
12
|
+
|
|
13
|
+
// 常用配置项的简写,get/set/delete 都可以直接用。
|
|
14
|
+
export const CONFIG_KEY_ALIASES = {
|
|
15
|
+
remote: 'defaults.remote',
|
|
16
|
+
'repo-mode': 'defaults.repoMode',
|
|
17
|
+
repoMode: 'defaults.repoMode',
|
|
18
|
+
main: 'defaults.defaultMainBranches',
|
|
19
|
+
'main-branches': 'defaults.defaultMainBranches',
|
|
20
|
+
'ai-profile': 'ai.activeProfile'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 这些配置项的值是列表,set 时支持逗号分隔。
|
|
24
|
+
const LIST_KEYS = new Set(['defaults.defaultMainBranches'])
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 解析配置路径:先查简写,再支持 token.<provider> / host.<域名> 这类快捷写法。
|
|
28
|
+
*/
|
|
29
|
+
export function resolveConfigKey(key) {
|
|
30
|
+
const raw = String(key || '').trim()
|
|
31
|
+
if (CONFIG_KEY_ALIASES[raw]) {
|
|
32
|
+
return CONFIG_KEY_ALIASES[raw]
|
|
33
|
+
}
|
|
34
|
+
const tokenMatch = raw.match(/^token\.(\w+)$/)
|
|
35
|
+
if (tokenMatch) {
|
|
36
|
+
return `platforms.tokens.${tokenMatch[1]}`
|
|
37
|
+
}
|
|
38
|
+
const hostMatch = raw.match(/^host\.(.+)$/)
|
|
39
|
+
if (hostMatch) {
|
|
40
|
+
return `platforms.hosts[${hostMatch[1]}]`
|
|
41
|
+
}
|
|
42
|
+
return raw
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 简写项的值做一次类型整理,例如 main=main,master -> ["main","master"]。
|
|
46
|
+
export function normalizeAliasValue(resolvedKey, value) {
|
|
47
|
+
if (LIST_KEYS.has(resolvedKey) && typeof value === 'string') {
|
|
48
|
+
return value.split(',').map((item) => item.trim()).filter(Boolean)
|
|
49
|
+
}
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function maskSecret(secret) {
|
|
54
|
+
const raw = String(secret || '').trim()
|
|
55
|
+
if (!raw) {
|
|
56
|
+
return ''
|
|
57
|
+
}
|
|
58
|
+
if (raw.length <= 8) {
|
|
59
|
+
return `${raw.slice(0, 2)}****${raw.slice(-1)}`
|
|
60
|
+
}
|
|
61
|
+
return `${raw.slice(0, 4)}****${raw.slice(-4)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SECRET_KEY_PATTERN = /api[_-]?key|token|secret|password|authorization/i
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 返回脱敏后的配置副本:密钥类字段和 platforms.tokens 下的值都只显示首尾。
|
|
68
|
+
*/
|
|
69
|
+
export function maskConfig(value, parentKey = '') {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value.map((item) => maskConfig(item, parentKey))
|
|
72
|
+
}
|
|
73
|
+
if (value && typeof value === 'object') {
|
|
74
|
+
const result = {}
|
|
75
|
+
for (const [key, item] of Object.entries(value)) {
|
|
76
|
+
const isSecret = (SECRET_KEY_PATTERN.test(key) || parentKey === 'tokens') && typeof item === 'string'
|
|
77
|
+
result[key] = isSecret ? maskSecret(item) : maskConfig(item, key)
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 用 $VISUAL / $EDITOR 打开全局配置文件,保存后校验 JSON。
|
|
86
|
+
*/
|
|
87
|
+
export function runConfigEdit() {
|
|
88
|
+
const filePath = getConfigFilePath()
|
|
89
|
+
readConfig()
|
|
90
|
+
const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === 'win32' ? 'notepad' : 'vi')
|
|
91
|
+
const result = spawnSync(editor, [filePath], { stdio: 'inherit', shell: true })
|
|
92
|
+
if (result.status !== 0) {
|
|
93
|
+
throw new IcodeError(`编辑器退出异常: ${editor}`, { code: 'CONFIG_EDIT_FAILED', exitCode: 2 })
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new IcodeError(`配置文件不是合法 JSON,请重新编辑: ${filePath}`, { code: 'CONFIG_PARSE_ERROR', exitCode: 2, cause: error })
|
|
99
|
+
}
|
|
100
|
+
logger.success(`配置已保存: ${filePath}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function ask(message, defaultValue, interactive) {
|
|
104
|
+
if (!interactive) {
|
|
105
|
+
return defaultValue
|
|
106
|
+
}
|
|
107
|
+
return (await input(message, defaultValue)).trim() || defaultValue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 在仓库根目录生成 .icoderc.json(团队共享配置),值默认取自当前仓库的检测结果。
|
|
112
|
+
* 输入为 { force, yes },输出写入的文件路径与内容。
|
|
113
|
+
*/
|
|
114
|
+
export async function runConfigInit(options = {}) {
|
|
115
|
+
const context = await resolveGitContext({ repoMode: options.repoMode })
|
|
116
|
+
const existing = findRepoConfigFile(context.topLevelPath)
|
|
117
|
+
if (existing && !options.force) {
|
|
118
|
+
throw new IcodeError(`已存在仓库配置: ${existing}。覆盖请加 --force。`, { code: 'CONFIG_INIT_EXISTS', exitCode: 2 })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const interactive = !options.yes && isInteractiveTerminal()
|
|
122
|
+
const policy = getRepoPolicy(context.topLevelPath)
|
|
123
|
+
const current = readRepoConfig(context.topLevelPath)
|
|
124
|
+
const defaultProtected = [...new Set([context.defaultBranch, ...(policy.protectedBranches || []), ...current.protectedBranches])].filter(Boolean)
|
|
125
|
+
|
|
126
|
+
const remote = await ask('默认 remote', current.remote || context.remote, interactive)
|
|
127
|
+
const defaultBranch = await ask('主分支', current.defaultBranch || context.defaultBranch, interactive)
|
|
128
|
+
const protectedBranches = (await ask('受保护分支(逗号分隔)', defaultProtected.join(','), interactive))
|
|
129
|
+
.split(',').map((item) => item.trim()).filter(Boolean)
|
|
130
|
+
const maxFileSizeMB = Number(await ask('提交时大文件阈值(MB)', String(current.save.maxFileSizeMB), interactive)) || 5
|
|
131
|
+
|
|
132
|
+
const content = {
|
|
133
|
+
remote,
|
|
134
|
+
defaultBranch,
|
|
135
|
+
protectedBranches,
|
|
136
|
+
save: {
|
|
137
|
+
maxFileSizeMB,
|
|
138
|
+
sensitivePatterns: current.save.sensitivePatterns,
|
|
139
|
+
allowPatterns: current.save.allowPatterns
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const filePath = existing || path.resolve(context.topLevelPath, '.icoderc.json')
|
|
143
|
+
fs.writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`, 'utf8')
|
|
144
|
+
return { filePath, content }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function printConfigOverview() {
|
|
148
|
+
const config = readConfig()
|
|
149
|
+
const tokens = SUPPORTED_PLATFORM_NAMES
|
|
150
|
+
.map((provider) => {
|
|
151
|
+
const { token, source } = resolvePlatformToken(provider, config)
|
|
152
|
+
return token ? `${provider}(${source.replace(/^config:/, '')})` : ''
|
|
153
|
+
})
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
logger.info(`配置文件: ${getConfigFilePath()}`)
|
|
156
|
+
logger.info(`默认 remote: ${config.defaults?.remote || '(自动)'} 主分支候选: ${(config.defaults?.defaultMainBranches || []).join(', ')}`)
|
|
157
|
+
logger.info(`AI profile: ${config.ai?.activeProfile || '(未配置)'} 共 ${Object.keys(config.ai?.profiles || {}).length} 个`)
|
|
158
|
+
logger.info(`平台 token: ${tokens.join(', ') || '(未配置)'}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function wizardPlatformToken() {
|
|
162
|
+
const provider = await chooseOne('选择平台:', SUPPORTED_PLATFORM_NAMES.map((name) => ({ value: name, label: name })), 0)
|
|
163
|
+
const token = (await input(`${provider} token(输入后回车)`, '')).trim()
|
|
164
|
+
if (!token) {
|
|
165
|
+
logger.warn('未输入,跳过。')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
setValue(`platforms.tokens.${provider}`, token)
|
|
169
|
+
logger.success(`已保存 ${provider} token: ${maskSecret(token)}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function wizardAiProfile() {
|
|
173
|
+
const format = await chooseOne('接口格式:', [
|
|
174
|
+
{ value: 'openai', label: 'OpenAI 兼容(OpenAI / DeepSeek / 通义 / 本地 vLLM 等)' },
|
|
175
|
+
{ value: 'anthropic', label: 'Anthropic' },
|
|
176
|
+
{ value: 'ollama', label: 'Ollama 本地模型' }
|
|
177
|
+
], 0)
|
|
178
|
+
const defaults = {
|
|
179
|
+
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
|
180
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-sonnet-4-5' },
|
|
181
|
+
ollama: { baseUrl: 'http://127.0.0.1:11434', model: 'qwen2.5:7b' }
|
|
182
|
+
}[format]
|
|
183
|
+
// 常见服务和模型直接选,只有自定义时才需要输入。
|
|
184
|
+
const presets = {
|
|
185
|
+
openai: [
|
|
186
|
+
{ value: 'https://api.openai.com/v1', label: 'OpenAI', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4.1-mini', 'gpt-4.1'] },
|
|
187
|
+
{ value: 'https://api.deepseek.com/v1', label: 'DeepSeek', models: ['deepseek-chat', 'deepseek-reasoner'] },
|
|
188
|
+
{ value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', label: '通义千问', models: ['qwen-plus', 'qwen-max', 'qwen-turbo'] },
|
|
189
|
+
{ value: 'https://open.bigmodel.cn/api/paas/v4', label: '智谱 GLM', models: ['glm-4-flash', 'glm-4-plus'] },
|
|
190
|
+
{ value: 'https://api.moonshot.cn/v1', label: 'Moonshot Kimi', models: ['moonshot-v1-8k', 'moonshot-v1-32k'] }
|
|
191
|
+
],
|
|
192
|
+
anthropic: [
|
|
193
|
+
{ value: 'https://api.anthropic.com/v1', label: 'Anthropic', models: ['claude-sonnet-4-5', 'claude-haiku-4-5', 'claude-opus-4-1'] }
|
|
194
|
+
],
|
|
195
|
+
ollama: [
|
|
196
|
+
{ value: 'http://127.0.0.1:11434', label: '本机 Ollama', models: ['qwen2.5:7b', 'qwen2.5-coder:7b', 'llama3.1:8b', 'deepseek-r1:7b'] }
|
|
197
|
+
]
|
|
198
|
+
}[format]
|
|
199
|
+
|
|
200
|
+
const service = await chooseOne('服务', [
|
|
201
|
+
...presets.map((item) => ({ value: item.value, label: item.label, hint: item.value })),
|
|
202
|
+
{ value: '__custom__', label: '其它(自己填 base url)' }
|
|
203
|
+
], 0, { filter: false })
|
|
204
|
+
const preset = presets.find((item) => item.value === service)
|
|
205
|
+
const baseUrl = service === '__custom__' ? ((await input('base url', defaults.baseUrl)).trim() || defaults.baseUrl) : service
|
|
206
|
+
|
|
207
|
+
const modelChoice = await chooseOne('模型', [
|
|
208
|
+
...(preset?.models || [defaults.model]).map((item) => ({ value: item, label: item })),
|
|
209
|
+
{ value: '__custom__', label: '其它(自己填)' }
|
|
210
|
+
], 0, { filter: false })
|
|
211
|
+
const model = modelChoice === '__custom__' ? ((await input('模型名称', defaults.model)).trim() || defaults.model) : modelChoice
|
|
212
|
+
const suggestedName = (preset?.label || format).toLowerCase().replace(/\s+/g, '-')
|
|
213
|
+
const name = (await input('profile 名称', suggestedName)).trim() || suggestedName
|
|
214
|
+
const apiKey = format === 'ollama' ? '' : (await input('API key', '')).trim()
|
|
215
|
+
upsertAiProfile(name, { format, baseUrl, model, ...(apiKey ? { apiKey } : {}) })
|
|
216
|
+
useAiProfile(name)
|
|
217
|
+
logger.success(`已保存并启用 AI profile: ${name}(可用 icode config ai test 测试连通性)`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function wizardRemoteAndBranches() {
|
|
221
|
+
const config = readConfig()
|
|
222
|
+
const remote = (await input('默认 remote(留空表示自动识别)', config.defaults?.remote || '')).trim()
|
|
223
|
+
const mains = (await input('主分支候选(逗号分隔)', (config.defaults?.defaultMainBranches || ['main', 'master']).join(','))).trim()
|
|
224
|
+
const next = {
|
|
225
|
+
...config,
|
|
226
|
+
defaults: {
|
|
227
|
+
...config.defaults,
|
|
228
|
+
remote,
|
|
229
|
+
defaultMainBranches: mains.split(',').map((item) => item.trim()).filter(Boolean)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!remote) {
|
|
233
|
+
delete next.defaults.remote
|
|
234
|
+
}
|
|
235
|
+
writeConfig(next)
|
|
236
|
+
logger.success('已保存。')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function wizardProtectedBranches() {
|
|
240
|
+
let repoRoot
|
|
241
|
+
try {
|
|
242
|
+
repoRoot = (await resolveGitContext({})).topLevelPath
|
|
243
|
+
} catch {
|
|
244
|
+
logger.warn('当前不在 Git 仓库中,无法设置受保护分支。')
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const current = getRepoPolicy(repoRoot).protectedBranches || []
|
|
248
|
+
const value = (await input('受保护分支(逗号分隔,留空清除)', current.join(','))).trim()
|
|
249
|
+
setRepoPolicy(repoRoot, { protectedBranches: value.split(',').map((item) => item.trim()).filter(Boolean) })
|
|
250
|
+
logger.success(`已保存(仅本机生效)。团队共享请用 icode config init 写入 .icoderc.json。`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 不带参数执行 icode config 时的引导式配置菜单。
|
|
255
|
+
*/
|
|
256
|
+
export async function runConfigWizard() {
|
|
257
|
+
printConfigOverview()
|
|
258
|
+
for (;;) {
|
|
259
|
+
const action = await chooseOne('要配置什么?', [
|
|
260
|
+
{ value: 'token', label: '平台 token(GitHub / GitLab / Gitee / Gitea,用于 PR、远程合并、Release)' },
|
|
261
|
+
{ value: 'ai', label: 'AI profile(AI 提交信息、代码评审)' },
|
|
262
|
+
{ value: 'remote', label: '默认 remote 和主分支' },
|
|
263
|
+
{ value: 'protect', label: '当前仓库的受保护分支' },
|
|
264
|
+
{ value: 'init', label: '为当前仓库生成团队共享配置 .icoderc.json' },
|
|
265
|
+
{ value: 'edit', label: '直接编辑配置文件' },
|
|
266
|
+
{ value: 'exit', label: '完成' }
|
|
267
|
+
], 0)
|
|
268
|
+
|
|
269
|
+
if (action === 'exit') {
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if (action === 'token') {
|
|
273
|
+
await wizardPlatformToken()
|
|
274
|
+
} else if (action === 'ai') {
|
|
275
|
+
await wizardAiProfile()
|
|
276
|
+
} else if (action === 'remote') {
|
|
277
|
+
await wizardRemoteAndBranches()
|
|
278
|
+
} else if (action === 'protect') {
|
|
279
|
+
await wizardProtectedBranches()
|
|
280
|
+
} else if (action === 'init') {
|
|
281
|
+
const force = Boolean(findRepoConfigFile((await resolveGitContext({})).topLevelPath)) && await confirm('已存在 .icoderc.json,是否覆盖?', false)
|
|
282
|
+
try {
|
|
283
|
+
const result = await runConfigInit({ force })
|
|
284
|
+
logger.success(`已生成 ${result.filePath},记得提交到仓库。`)
|
|
285
|
+
} catch (error) {
|
|
286
|
+
logger.warn(error.message)
|
|
287
|
+
}
|
|
288
|
+
} else if (action === 'edit') {
|
|
289
|
+
runConfigEdit()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/commands/config.js
CHANGED
|
@@ -21,58 +21,72 @@ import {
|
|
|
21
21
|
readConfig,
|
|
22
22
|
setPlatformConfig,
|
|
23
23
|
setRepoPolicy,
|
|
24
|
-
setValue
|
|
24
|
+
setValue,
|
|
25
|
+
writeConfig
|
|
25
26
|
} from '../core/config/store.js'
|
|
27
|
+
import { resolvePlatformToken, SUPPORTED_PLATFORM_NAMES } from '../core/platform/index.js'
|
|
28
|
+
import { readRepoConfig } from '../core/config/repo-config.js'
|
|
29
|
+
import { isInteractiveTerminal } from '../core/tools/interactive.js'
|
|
30
|
+
import {
|
|
31
|
+
maskConfig,
|
|
32
|
+
maskSecret,
|
|
33
|
+
normalizeAliasValue,
|
|
34
|
+
resolveConfigKey,
|
|
35
|
+
runConfigEdit,
|
|
36
|
+
runConfigInit,
|
|
37
|
+
runConfigWizard
|
|
38
|
+
} from './config-ux.js'
|
|
26
39
|
import { normalizeLegacyArgs, parseConfigValue } from '../core/cli/args.js'
|
|
27
40
|
import { IcodeError } from '../core/errors.js'
|
|
28
41
|
import { resolveGitContext } from '../core/git/context.js'
|
|
29
42
|
import { logger } from '../core/tools/logger.js'
|
|
30
43
|
|
|
31
|
-
// 输出 config
|
|
44
|
+
// 输出 config 命令帮助。
|
|
32
45
|
function printHelp() {
|
|
33
46
|
process.stdout.write(`
|
|
34
47
|
Usage:
|
|
48
|
+
icode config 引导式配置(交互终端中)
|
|
35
49
|
icode config <command> [...args]
|
|
36
50
|
|
|
37
|
-
Arguments:
|
|
38
|
-
<command> 子命令(list/get/set/delete/ai/protect/platform)
|
|
39
|
-
|
|
40
51
|
Commands:
|
|
41
|
-
list
|
|
42
|
-
get <
|
|
43
|
-
set <
|
|
44
|
-
delete <
|
|
52
|
+
list [--show-secrets] 查看全部配置(默认脱敏),同时显示当前仓库的 .icoderc.json
|
|
53
|
+
get <key> 读取配置项(密钥默认脱敏)
|
|
54
|
+
set <key> <value> 写入配置项(值支持数字/布尔/JSON)
|
|
55
|
+
delete <key> 删除配置项
|
|
56
|
+
path 输出配置文件路径
|
|
57
|
+
edit 用 $EDITOR 打开配置文件,保存时校验 JSON
|
|
58
|
+
init [--force] [-y] 为当前仓库生成团队共享的 .icoderc.json
|
|
45
59
|
ai <subcommand> [...] AI profile 配置
|
|
46
|
-
protect list
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
protect list|add|remove 当前仓库的受保护分支(仅本机)
|
|
61
|
+
platform <subcommand> 平台 token / 自建域名映射
|
|
62
|
+
|
|
63
|
+
Key 简写:
|
|
64
|
+
remote -> defaults.remote
|
|
65
|
+
repo-mode -> defaults.repoMode
|
|
66
|
+
main -> defaults.defaultMainBranches(逗号分隔)
|
|
67
|
+
ai-profile -> ai.activeProfile
|
|
68
|
+
token.<平台> -> platforms.tokens.<平台>
|
|
69
|
+
host.<域名> -> platforms.hosts[<域名>]
|
|
70
|
+
路径里的键带点时用方括号:platforms.hosts[git.company.com]
|
|
50
71
|
|
|
51
72
|
Options:
|
|
52
|
-
--repo-mode <mode> 仓库模式: auto
|
|
73
|
+
--repo-mode <mode> 仓库模式: auto | strict(影响 protect / init)
|
|
74
|
+
--show-secrets list/get 时显示密钥原文
|
|
53
75
|
-h, --help 查看帮助
|
|
54
76
|
|
|
55
77
|
Examples:
|
|
56
|
-
icode config
|
|
57
|
-
icode config
|
|
78
|
+
icode config
|
|
79
|
+
icode config set remote upstream
|
|
80
|
+
icode config set main main,master,develop
|
|
81
|
+
icode config set token.github ghp_xxx
|
|
82
|
+
icode config set host.git.company.com gitlab
|
|
83
|
+
icode config get remote
|
|
84
|
+
icode config init
|
|
58
85
|
icode config protect add main release
|
|
59
|
-
icode config platform remote-merge set --provider gitlab --api-key rm_xxx
|
|
60
86
|
icode config ai set openai --format openai --base-url https://api.openai.com/v1 --api-key sk-xxx --model gpt-4o-mini --activate
|
|
61
|
-
icode config ai set ollama --format ollama --base-url http://127.0.0.1:11434 --model qwen2.5:7b --activate
|
|
62
87
|
`)
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
function maskSecret(secret) {
|
|
66
|
-
const raw = String(secret || '').trim()
|
|
67
|
-
if (!raw) {
|
|
68
|
-
return ''
|
|
69
|
-
}
|
|
70
|
-
if (raw.length <= 8) {
|
|
71
|
-
return `${raw.slice(0, 2)}****${raw.slice(-1)}`
|
|
72
|
-
}
|
|
73
|
-
return `${raw.slice(0, 4)}****${raw.slice(-4)}`
|
|
74
|
-
}
|
|
75
|
-
|
|
76
90
|
// 输出 platform 子命令帮助,明确 remote-merge 仅需平台名与密钥。
|
|
77
91
|
function printPlatformHelp() {
|
|
78
92
|
process.stdout.write(`
|
|
@@ -80,16 +94,97 @@ Usage:
|
|
|
80
94
|
icode config platform <subcommand> [options]
|
|
81
95
|
|
|
82
96
|
Subcommands:
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
token show 查看各平台 token(脱敏)与来源
|
|
98
|
+
token set <provider> <token> 写入平台 token(github | gitlab | gitee | gitea)
|
|
99
|
+
token remove <provider> 删除平台 token
|
|
100
|
+
host set <host> <provider> 把自建平台域名映射到 provider(例如 git.company.com gitlab)
|
|
101
|
+
host remove <host>
|
|
102
|
+
remote-merge show (兼容旧命令)
|
|
103
|
+
remote-merge set --provider <name> --api-key <key>
|
|
104
|
+
|
|
105
|
+
Notes:
|
|
106
|
+
平台按 remote 地址的域名自动识别;token 也可以用环境变量提供:
|
|
107
|
+
GITHUB_TOKEN / GH_TOKEN、GITLAB_TOKEN、GITEE_TOKEN、GITEA_TOKEN。
|
|
85
108
|
|
|
86
109
|
Examples:
|
|
87
|
-
icode config platform
|
|
88
|
-
icode config platform
|
|
110
|
+
icode config platform token set github ghp_xxx
|
|
111
|
+
icode config platform token set gitlab glpat-xxx
|
|
112
|
+
icode config platform host set git.company.com gitlab
|
|
113
|
+
icode config platform token show
|
|
89
114
|
`)
|
|
90
115
|
}
|
|
91
116
|
|
|
92
|
-
|
|
117
|
+
function assertSupportedProvider(provider) {
|
|
118
|
+
if (!SUPPORTED_PLATFORM_NAMES.includes(provider)) {
|
|
119
|
+
throw new IcodeError(`不支持的平台: ${provider || '(空)'},可选 ${SUPPORTED_PLATFORM_NAMES.join(', ')}`, {
|
|
120
|
+
code: 'CONFIG_PLATFORM_PROVIDER_UNSUPPORTED',
|
|
121
|
+
exitCode: 2
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 平台 token 管理:platforms.tokens.<provider>。
|
|
127
|
+
function runPlatformTokenCommand(action, rest) {
|
|
128
|
+
if (action === 'show' || !action) {
|
|
129
|
+
const config = readConfig()
|
|
130
|
+
const view = {}
|
|
131
|
+
for (const provider of SUPPORTED_PLATFORM_NAMES) {
|
|
132
|
+
const { token, source } = resolvePlatformToken(provider, config)
|
|
133
|
+
view[provider] = { hasToken: Boolean(token), tokenMasked: maskSecret(token), source }
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(`${JSON.stringify({ tokens: view, hosts: config.platforms?.hosts || {} }, null, 2)}\n`)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const provider = String(rest[0] || '').trim().toLowerCase()
|
|
140
|
+
assertSupportedProvider(provider)
|
|
141
|
+
|
|
142
|
+
if (action === 'set') {
|
|
143
|
+
const token = String(rest[1] || '').trim()
|
|
144
|
+
if (!token) {
|
|
145
|
+
throw new IcodeError(`缺少 token: icode config platform token set ${provider} <token>`, {
|
|
146
|
+
code: 'CONFIG_PLATFORM_TOKEN_REQUIRED',
|
|
147
|
+
exitCode: 2
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
setValue(`platforms.tokens.${provider}`, token)
|
|
151
|
+
logger.success(`已写入 ${provider} token: ${maskSecret(token)}`)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (action === 'remove') {
|
|
156
|
+
deleteValue(`platforms.tokens.${provider}`)
|
|
157
|
+
logger.success(`已删除 ${provider} token`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw new IcodeError('platform token 仅支持: show | set | remove', { code: 'CONFIG_PLATFORM_INVALID_ACTION', exitCode: 2 })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 自建平台域名映射:platforms.hosts.<host> = provider。域名里的点不能用 setValue 的点路径,直接改对象。
|
|
165
|
+
function runPlatformHostCommand(action, rest) {
|
|
166
|
+
const host = String(rest[0] || '').trim().toLowerCase()
|
|
167
|
+
if (!host) {
|
|
168
|
+
throw new IcodeError('缺少域名: icode config platform host set <host> <provider>', { code: 'CONFIG_PLATFORM_HOST_REQUIRED', exitCode: 2 })
|
|
169
|
+
}
|
|
170
|
+
const config = readConfig()
|
|
171
|
+
const hosts = { ...(config.platforms?.hosts || {}) }
|
|
172
|
+
|
|
173
|
+
if (action === 'set') {
|
|
174
|
+
const provider = String(rest[1] || '').trim().toLowerCase()
|
|
175
|
+
assertSupportedProvider(provider)
|
|
176
|
+
hosts[host] = provider
|
|
177
|
+
} else if (action === 'remove') {
|
|
178
|
+
delete hosts[host]
|
|
179
|
+
} else {
|
|
180
|
+
throw new IcodeError('platform host 仅支持: set | remove', { code: 'CONFIG_PLATFORM_INVALID_ACTION', exitCode: 2 })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeConfig({ ...config, platforms: { ...(config.platforms || {}), hosts } })
|
|
184
|
+
logger.success(action === 'set' ? `已映射 ${host} -> ${hosts[host]}` : `已移除 ${host}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 处理平台级远程合并配置(旧命令,保留兼容)。
|
|
93
188
|
async function runPlatformConfigCommand(args) {
|
|
94
189
|
if (!args.length || args[0] === '-h' || args[0] === '--help') {
|
|
95
190
|
printPlatformHelp()
|
|
@@ -97,6 +192,14 @@ async function runPlatformConfigCommand(args) {
|
|
|
97
192
|
}
|
|
98
193
|
|
|
99
194
|
const [domain, action, ...rest] = args
|
|
195
|
+
if (domain === 'token') {
|
|
196
|
+
runPlatformTokenCommand(action, rest)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (domain === 'host') {
|
|
200
|
+
runPlatformHostCommand(action, rest)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
100
203
|
if (domain !== 'remote-merge') {
|
|
101
204
|
throw new IcodeError(`未知 platform 子命令: ${domain}`, {
|
|
102
205
|
code: 'CONFIG_PLATFORM_UNKNOWN_SUBCOMMAND',
|
|
@@ -141,8 +244,8 @@ async function runPlatformConfigCommand(args) {
|
|
|
141
244
|
})
|
|
142
245
|
}
|
|
143
246
|
|
|
144
|
-
if (provider
|
|
145
|
-
throw new IcodeError(`platform remote-merge
|
|
247
|
+
if (!SUPPORTED_PLATFORM_NAMES.includes(provider)) {
|
|
248
|
+
throw new IcodeError(`platform remote-merge 仅支持 ${SUPPORTED_PLATFORM_NAMES.join(', ')},收到 provider=${provider}`, {
|
|
146
249
|
code: 'CONFIG_PLATFORM_PROVIDER_UNSUPPORTED',
|
|
147
250
|
exitCode: 2
|
|
148
251
|
})
|
|
@@ -504,11 +607,23 @@ export async function runConfigCommand(rawArgs) {
|
|
|
504
607
|
allowPositionals: true,
|
|
505
608
|
options: {
|
|
506
609
|
help: { type: 'boolean', short: 'h', default: false },
|
|
507
|
-
'repo-mode': { type: 'string', default: 'auto' }
|
|
610
|
+
'repo-mode': { type: 'string', default: 'auto' },
|
|
611
|
+
'show-secrets': { type: 'boolean', default: false },
|
|
612
|
+
force: { type: 'boolean', default: false },
|
|
613
|
+
yes: { type: 'boolean', short: 'y', default: false }
|
|
508
614
|
}
|
|
509
615
|
})
|
|
510
616
|
|
|
511
|
-
if (parsed.values.help
|
|
617
|
+
if (parsed.values.help) {
|
|
618
|
+
printHelp()
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (parsed.positionals.length === 0) {
|
|
623
|
+
if (isInteractiveTerminal()) {
|
|
624
|
+
await runConfigWizard()
|
|
625
|
+
return
|
|
626
|
+
}
|
|
512
627
|
printHelp()
|
|
513
628
|
return
|
|
514
629
|
}
|
|
@@ -516,13 +631,42 @@ export async function runConfigCommand(rawArgs) {
|
|
|
516
631
|
const [command, ...rest] = parsed.positionals
|
|
517
632
|
|
|
518
633
|
if (command === 'list') {
|
|
519
|
-
|
|
520
|
-
|
|
634
|
+
const config = readConfig()
|
|
635
|
+
process.stdout.write(`${JSON.stringify(parsed.values['show-secrets'] ? config : maskConfig(config), null, 2)}\n`)
|
|
636
|
+
logger.info(`配置文件路径: ${getConfigFilePath()}${parsed.values['show-secrets'] ? '' : '(密钥已脱敏,--show-secrets 查看原文)'}`)
|
|
637
|
+
try {
|
|
638
|
+
const context = await resolveGitContext({ repoMode: parsed.values['repo-mode'] })
|
|
639
|
+
const repoConfig = readRepoConfig(context.topLevelPath)
|
|
640
|
+
if (repoConfig.filePath) {
|
|
641
|
+
const { filePath, ...content } = repoConfig
|
|
642
|
+
logger.info(`仓库配置 ${filePath}:`)
|
|
643
|
+
process.stdout.write(`${JSON.stringify(content, null, 2)}\n`)
|
|
644
|
+
}
|
|
645
|
+
} catch {
|
|
646
|
+
// 不在仓库中时只显示全局配置。
|
|
647
|
+
}
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (command === 'path') {
|
|
652
|
+
process.stdout.write(`${getConfigFilePath()}\n`)
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (command === 'edit') {
|
|
657
|
+
runConfigEdit()
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (command === 'init') {
|
|
662
|
+
const result = await runConfigInit({ force: parsed.values.force, yes: parsed.values.yes, repoMode: parsed.values['repo-mode'] })
|
|
663
|
+
process.stdout.write(`${JSON.stringify(result.content, null, 2)}\n`)
|
|
664
|
+
logger.success(`已生成 ${result.filePath},提交到仓库后团队成员共享这些设置。`)
|
|
521
665
|
return
|
|
522
666
|
}
|
|
523
667
|
|
|
524
668
|
if (command === 'get') {
|
|
525
|
-
const pathExpression = rest[0]
|
|
669
|
+
const pathExpression = resolveConfigKey(rest[0])
|
|
526
670
|
if (!pathExpression) {
|
|
527
671
|
throw new IcodeError('缺少配置路径: icode config get <path>', {
|
|
528
672
|
code: 'CONFIG_GET_PATH_REQUIRED',
|
|
@@ -530,12 +674,18 @@ export async function runConfigCommand(rawArgs) {
|
|
|
530
674
|
})
|
|
531
675
|
}
|
|
532
676
|
|
|
533
|
-
const
|
|
534
|
-
if (
|
|
535
|
-
logger.warn(
|
|
677
|
+
const rawValue = getValue(pathExpression)
|
|
678
|
+
if (rawValue === undefined) {
|
|
679
|
+
logger.warn(`配置不存在: ${pathExpression}`)
|
|
536
680
|
return
|
|
537
681
|
}
|
|
538
682
|
|
|
683
|
+
const lastSegment = pathExpression.split('.').pop() || ''
|
|
684
|
+
const isSecretKey = /api[_-]?key|token|secret|password/i.test(lastSegment) || /^platforms\.tokens\./.test(pathExpression)
|
|
685
|
+
const value = parsed.values['show-secrets']
|
|
686
|
+
? rawValue
|
|
687
|
+
: typeof rawValue === 'object' ? maskConfig(rawValue, lastSegment) : isSecretKey ? maskSecret(rawValue) : rawValue
|
|
688
|
+
|
|
539
689
|
if (typeof value === 'object') {
|
|
540
690
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
|
|
541
691
|
} else {
|
|
@@ -546,7 +696,8 @@ export async function runConfigCommand(rawArgs) {
|
|
|
546
696
|
}
|
|
547
697
|
|
|
548
698
|
if (command === 'set') {
|
|
549
|
-
const [
|
|
699
|
+
const [rawKey, rawValue] = rest
|
|
700
|
+
const pathExpression = resolveConfigKey(rawKey)
|
|
550
701
|
if (!pathExpression || rawValue == null) {
|
|
551
702
|
throw new IcodeError('缺少参数: icode config set <path> <value>', {
|
|
552
703
|
code: 'CONFIG_SET_ARGUMENTS_REQUIRED',
|
|
@@ -554,14 +705,14 @@ export async function runConfigCommand(rawArgs) {
|
|
|
554
705
|
})
|
|
555
706
|
}
|
|
556
707
|
|
|
557
|
-
const value = parseConfigValue(rawValue)
|
|
708
|
+
const value = normalizeAliasValue(pathExpression, parseConfigValue(rawValue))
|
|
558
709
|
setValue(pathExpression, value)
|
|
559
|
-
logger.success(`已写入: ${pathExpression}`)
|
|
710
|
+
logger.success(`已写入: ${pathExpression}${pathExpression !== rawKey ? `(${rawKey})` : ''}`)
|
|
560
711
|
return
|
|
561
712
|
}
|
|
562
713
|
|
|
563
714
|
if (command === 'delete') {
|
|
564
|
-
const
|
|
715
|
+
const pathExpression = resolveConfigKey(rest[0])
|
|
565
716
|
if (!pathExpression) {
|
|
566
717
|
throw new IcodeError('缺少参数: icode config delete <path>', {
|
|
567
718
|
code: 'CONFIG_DELETE_PATH_REQUIRED',
|