@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.
Files changed (75) hide show
  1. package/README.md +287 -24
  2. package/bin/icode.js +10 -0
  3. package/package.json +3 -3
  4. package/src/cli.js +175 -8
  5. package/src/commands/checkout.js +73 -6
  6. package/src/commands/clean.js +47 -7
  7. package/src/commands/config-ux.js +292 -0
  8. package/src/commands/config.js +199 -48
  9. package/src/commands/continue.js +104 -0
  10. package/src/commands/doctor.js +15 -29
  11. package/src/commands/help.js +54 -34
  12. package/src/commands/history.js +147 -0
  13. package/src/commands/info.js +39 -4
  14. package/src/commands/log.js +142 -0
  15. package/src/commands/migrate.js +19 -12
  16. package/src/commands/open.js +138 -0
  17. package/src/commands/pr.js +219 -0
  18. package/src/commands/push.js +62 -22
  19. package/src/commands/ready.js +18 -48
  20. package/src/commands/release.js +112 -0
  21. package/src/commands/rescue.js +164 -0
  22. package/src/commands/save.js +52 -11
  23. package/src/commands/stash.js +210 -0
  24. package/src/commands/sw.js +89 -0
  25. package/src/commands/sync.js +19 -2
  26. package/src/commands/tag.js +40 -1
  27. package/src/commands/undo.js +9 -3
  28. package/src/core/ai/diff-range.js +1 -1
  29. package/src/core/cli/completion.js +237 -1
  30. package/src/core/cli/output.js +46 -0
  31. package/src/core/cli/render.js +121 -0
  32. package/src/core/commands/shell.js +9 -1
  33. package/src/core/config/repo-config.js +93 -0
  34. package/src/core/config/store.js +60 -10
  35. package/src/core/git/autostash.js +46 -0
  36. package/src/core/git/backup.js +170 -0
  37. package/src/core/git/conflicts.js +58 -0
  38. package/src/core/git/context.js +72 -5
  39. package/src/core/git/file-guard.js +193 -0
  40. package/src/core/git/operation-state.js +63 -0
  41. package/src/core/git/operations.js +32 -0
  42. package/src/core/git/semver.js +194 -0
  43. package/src/core/git/service.js +454 -24
  44. package/src/core/platform/http.js +53 -0
  45. package/src/core/platform/index.js +165 -0
  46. package/src/core/platform/providers.js +454 -0
  47. package/src/core/platform/remote-url.js +92 -0
  48. package/src/core/remote-merge/client.js +33 -3
  49. package/src/core/tools/browser.js +28 -0
  50. package/src/core/tools/commit-prompt.js +137 -0
  51. package/src/core/tools/interactive.js +272 -40
  52. package/src/core/tools/logger.js +33 -23
  53. package/src/core/tools/theme.js +228 -0
  54. package/src/workflows/ai-commit-workflow.js +52 -20
  55. package/src/workflows/checkout-workflow.js +24 -10
  56. package/src/workflows/clean-workflow.js +135 -83
  57. package/src/workflows/continue-workflow.js +233 -0
  58. package/src/workflows/doctor-workflow.js +10 -7
  59. package/src/workflows/history-workflow.js +211 -0
  60. package/src/workflows/migrate-workflow.js +39 -14
  61. package/src/workflows/pr-workflow.js +252 -0
  62. package/src/workflows/push/commit-step.js +123 -0
  63. package/src/workflows/push/local-merge-step.js +213 -0
  64. package/src/workflows/push/plan.js +63 -0
  65. package/src/workflows/push/remote-merge-step.js +224 -0
  66. package/src/workflows/push-workflow.js +185 -492
  67. package/src/workflows/ready-workflow.js +14 -10
  68. package/src/workflows/release-workflow.js +189 -0
  69. package/src/workflows/rescue-workflow.js +137 -0
  70. package/src/workflows/save-workflow.js +106 -41
  71. package/src/workflows/switch-workflow.js +171 -0
  72. package/src/workflows/sync-workflow.js +143 -44
  73. package/src/workflows/tag-workflow.js +103 -17
  74. package/src/workflows/undo-workflow.js +113 -34
  75. 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
+ }
@@ -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 <path> 读取指定配置项
43
- set <path> <value> 写入配置项
44
- delete <path> 删除配置项
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
- protect add <branch...> 添加受保护分支
48
- protect remove <branch...> 移除受保护分支
49
- platform <subcommand> 平台级配置(如远程合并密钥)
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(自动继承父仓库) | strict(禁止继承),仅影响 protect
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 set defaults.repoMode strict
57
- icode config get defaults.repoMode
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
- remote-merge show
84
- remote-merge set --provider gitlab --api-key <key>
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 remote-merge show
88
- icode config platform remote-merge set --provider gitlab --api-key rm_xxx
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
- // 处理平台级远程合并配置,当前只接受 GitLab 平台密钥写入。
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 !== 'gitlab') {
145
- throw new IcodeError(`platform remote-merge 当前仅支持 gitlab,收到 provider=${provider}`, {
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 || parsed.positionals.length === 0) {
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
- process.stdout.write(`${JSON.stringify(readConfig(), null, 2)}\n`)
520
- logger.info(`配置文件路径: ${getConfigFilePath()}`)
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 value = getValue(pathExpression)
534
- if (value === undefined) {
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 [pathExpression, rawValue] = rest
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 [pathExpression] = rest
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',