@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,92 @@
1
+ /**
2
+ * 解析 git remote URL。
3
+ * 支持 https://host/a/b.git、ssh://git@host:22/a/b.git、git@host:a/b.git 三种格式。
4
+ * 输出 { host, protocol, path, owner, repo, webUrl };无法识别时返回 null。
5
+ */
6
+ export function parseRemoteUrl(remoteUrl = '') {
7
+ const raw = String(remoteUrl || '').trim()
8
+ if (!raw) {
9
+ return null
10
+ }
11
+
12
+ let host = ''
13
+ let repoPath = ''
14
+ let protocol = 'https:'
15
+
16
+ if (/^https?:\/\//i.test(raw)) {
17
+ try {
18
+ const parsed = new URL(raw)
19
+ host = parsed.host
20
+ protocol = parsed.protocol
21
+ repoPath = parsed.pathname
22
+ } catch {
23
+ return null
24
+ }
25
+ } else if (/^ssh:\/\//i.test(raw)) {
26
+ try {
27
+ const parsed = new URL(raw)
28
+ // ssh 端口通常不是 web 端口,web 链接只保留主机名。
29
+ host = parsed.hostname
30
+ repoPath = parsed.pathname
31
+ } catch {
32
+ return null
33
+ }
34
+ } else {
35
+ const scpMatch = raw.match(/^(?:[^@]+@)?([^:/]+):(.+)$/)
36
+ if (!scpMatch) {
37
+ return null
38
+ }
39
+ host = scpMatch[1]
40
+ repoPath = scpMatch[2]
41
+ }
42
+
43
+ repoPath = repoPath.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '')
44
+ if (!host || !repoPath || !repoPath.includes('/')) {
45
+ return null
46
+ }
47
+
48
+ const segments = repoPath.split('/')
49
+ const repo = segments[segments.length - 1]
50
+ const owner = segments.slice(0, -1).join('/')
51
+
52
+ return {
53
+ host,
54
+ protocol,
55
+ path: repoPath,
56
+ owner,
57
+ repo,
58
+ webUrl: `${protocol}//${host}/${repoPath}`
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 根据主机名推断代码托管平台。
64
+ * 输入为主机名与 { hosts } 显式映射(来自配置 platforms.hosts),输出 github | gitlab | gitee | gitea | ''。
65
+ */
66
+ export function detectProviderFromHost(host = '', hostMap = {}) {
67
+ const normalizedHost = String(host || '').trim().toLowerCase()
68
+ if (!normalizedHost) {
69
+ return ''
70
+ }
71
+
72
+ const mapped = hostMap?.[normalizedHost] || hostMap?.[normalizedHost.replace(/:\d+$/, '')]
73
+ if (mapped) {
74
+ return String(mapped).trim().toLowerCase()
75
+ }
76
+
77
+ const bareHost = normalizedHost.replace(/:\d+$/, '')
78
+ if (bareHost === 'github.com' || bareHost.endsWith('.github.com') || bareHost.includes('github')) {
79
+ return 'github'
80
+ }
81
+ if (bareHost === 'gitee.com') {
82
+ return 'gitee'
83
+ }
84
+ if (bareHost === 'gitlab.com' || bareHost.includes('gitlab')) {
85
+ return 'gitlab'
86
+ }
87
+ if (bareHost === 'codeberg.org' || bareHost.includes('gitea') || bareHost.includes('forgejo')) {
88
+ return 'gitea'
89
+ }
90
+
91
+ return ''
92
+ }
@@ -1,16 +1,46 @@
1
1
  import { IcodeError } from '../errors.js'
2
+ import { createPlatformClient, performPlatformRemoteMerge } from '../platform/index.js'
3
+ import { parseRemoteUrl } from '../platform/remote-url.js'
2
4
  import { requestGitLabRemoteMerge, resolveGitLabProjectUrlFromRemote } from './providers/gitlab.js'
3
5
 
6
+ // 非 GitLab 平台统一走通用平台客户端:创建 PR -> 等待可合并 -> 合并。
7
+ function createGenericProvider(provider) {
8
+ return {
9
+ resolveProjectUrlFromRemote(remoteUrl) {
10
+ return parseRemoteUrl(remoteUrl)?.webUrl || ''
11
+ },
12
+ async requestRemoteMerge(options) {
13
+ const client = createPlatformClient(options.projectUrl, {
14
+ provider,
15
+ token: String(options.apiKey || '').trim()
16
+ })
17
+ return performPlatformRemoteMerge(client, {
18
+ sourceBranch: options.sourceBranch,
19
+ targetBranch: options.targetBranch,
20
+ title: options.mergeRequestTitle,
21
+ body: options.mergeRequestDescription,
22
+ pollIntervalMs: options.pollIntervalMs,
23
+ maxAttempts: options.maxAttempts
24
+ })
25
+ }
26
+ }
27
+ }
28
+
4
29
  const REMOTE_MERGE_PROVIDERS = {
5
30
  gitlab: {
6
31
  requestRemoteMerge: requestGitLabRemoteMerge,
7
32
  resolveProjectUrlFromRemote: resolveGitLabProjectUrlFromRemote
8
- }
33
+ },
34
+ github: createGenericProvider('github'),
35
+ gitee: createGenericProvider('gitee'),
36
+ gitea: createGenericProvider('gitea')
9
37
  }
10
38
 
39
+ export const REMOTE_MERGE_PROVIDER_NAMES = Object.keys(REMOTE_MERGE_PROVIDERS)
40
+
11
41
  // 按 provider 读取远程合并实现,缺失或不支持时统一抛出明确错误。
12
42
  function getRemoteMergeProvider(provider) {
13
- const normalizedProvider = String(provider || '').trim()
43
+ const normalizedProvider = String(provider || '').trim().toLowerCase()
14
44
  if (!normalizedProvider) {
15
45
  throw new IcodeError('远程合并缺少 provider', {
16
46
  code: 'REMOTE_MERGE_PROVIDER_EMPTY',
@@ -20,7 +50,7 @@ function getRemoteMergeProvider(provider) {
20
50
 
21
51
  const handler = REMOTE_MERGE_PROVIDERS[normalizedProvider]
22
52
  if (!handler) {
23
- throw new IcodeError(`当前仅支持: ${Object.keys(REMOTE_MERGE_PROVIDERS).join(', ')},收到 provider=${normalizedProvider}`, {
53
+ throw new IcodeError(`当前仅支持: ${REMOTE_MERGE_PROVIDER_NAMES.join(', ')},收到 provider=${normalizedProvider}`, {
24
54
  code: 'REMOTE_MERGE_PROVIDER_UNSUPPORTED',
25
55
  exitCode: 2
26
56
  })
@@ -0,0 +1,28 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ /**
4
+ * 用系统默认浏览器打开 URL。
5
+ * 输入为 URL,输出是否成功拉起浏览器;失败时不抛错,调用方会把链接打印出来。
6
+ * 设置 ICODE_NO_BROWSER=1(或在 CI 中)时不打开。
7
+ */
8
+ export function openInBrowser(url) {
9
+ if (!url || process.env.ICODE_NO_BROWSER === '1' || process.env.CI) {
10
+ return false
11
+ }
12
+
13
+ const platform = process.platform
14
+ const [command, args] = platform === 'darwin'
15
+ ? ['open', [url]]
16
+ : platform === 'win32'
17
+ ? ['cmd', ['/c', 'start', '""', url.replace(/&/g, '^&')]]
18
+ : ['xdg-open', [url]]
19
+
20
+ try {
21
+ const child = spawn(command, args, { stdio: 'ignore', detached: true })
22
+ child.on('error', () => {})
23
+ child.unref()
24
+ return true
25
+ } catch {
26
+ return false
27
+ }
28
+ }
@@ -0,0 +1,137 @@
1
+ import path from 'node:path'
2
+ import { getAiConfig } from '../ai/config.js'
3
+ import { chooseOne, input } from './interactive.js'
4
+ import { c } from './theme.js'
5
+
6
+ // 交互式提交信息向导:选类型 -> 选 scope -> 只写一句描述,尽量减少手动输入。
7
+
8
+ const COMMIT_TYPES = [
9
+ { value: 'feat', label: 'feat', hint: '新功能' },
10
+ { value: 'fix', label: 'fix', hint: '修复问题' },
11
+ { value: 'refactor', label: 'refactor', hint: '重构(不改功能)' },
12
+ { value: 'perf', label: 'perf', hint: '性能优化' },
13
+ { value: 'docs', label: 'docs', hint: '文档' },
14
+ { value: 'style', label: 'style', hint: '格式调整(不影响逻辑)' },
15
+ { value: 'test', label: 'test', hint: '测试' },
16
+ { value: 'chore', label: 'chore', hint: '杂项 / 依赖 / 配置' },
17
+ { value: 'build', label: 'build', hint: '构建系统' },
18
+ { value: 'ci', label: 'ci', hint: 'CI 配置' },
19
+ { value: 'revert', label: 'revert', hint: '回退' }
20
+ ]
21
+
22
+ // 这些目录名作为 scope 没有信息量,跳过取下一级。
23
+ const GENERIC_DIRS = new Set(['src', 'lib', 'app', 'apps', 'packages', 'test', 'tests', '__tests__', 'components', 'pages', 'core', 'utils', 'common'])
24
+
25
+ /**
26
+ * 根据改动文件推断 scope 候选:取最有意义的目录名或文件名,按出现次数排序。
27
+ * 输入为相对路径数组,输出 scope 字符串数组(最多 6 个)。
28
+ */
29
+ export function suggestScopes(files = []) {
30
+ const counts = new Map()
31
+ for (const file of files) {
32
+ const segments = String(file).split('/').filter(Boolean)
33
+ const dirs = segments.slice(0, -1)
34
+ let candidate = dirs.find((dir) => !GENERIC_DIRS.has(dir.toLowerCase()) && !dir.startsWith('.'))
35
+ if (!candidate) {
36
+ const base = path.posix.basename(file).replace(/\.[^.]+$/, '')
37
+ candidate = base && !base.startsWith('.') ? base : ''
38
+ }
39
+ if (packagesScope(segments)) {
40
+ candidate = packagesScope(segments)
41
+ }
42
+ if (candidate) {
43
+ counts.set(candidate, (counts.get(candidate) || 0) + 1)
44
+ }
45
+ }
46
+ return [...counts.entries()]
47
+ .sort((a, b) => b[1] - a[1])
48
+ .map(([scope]) => scope.toLowerCase())
49
+ .slice(0, 6)
50
+ }
51
+
52
+ // monorepo 的 packages/<name>/... 直接用包名做 scope。
53
+ function packagesScope(segments) {
54
+ const index = segments.findIndex((segment) => segment === 'packages' || segment === 'apps')
55
+ return index !== -1 && segments[index + 1] ? segments[index + 1] : ''
56
+ }
57
+
58
+ // 根据文件猜一个默认类型:只有文档 -> docs,只有测试 -> test,只有配置 -> chore。
59
+ export function suggestType(files = []) {
60
+ if (!files.length) {
61
+ return 'feat'
62
+ }
63
+ if (files.every((file) => /\.(md|mdx|txt|rst)$/i.test(file) || /^docs\//.test(file))) {
64
+ return 'docs'
65
+ }
66
+ if (files.every((file) => /(^|\/)(__tests__|tests?)\/|\.(test|spec)\.[jt]sx?$/.test(file))) {
67
+ return 'test'
68
+ }
69
+ if (files.every((file) => /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|\.[\w-]+rc(\.\w+)?|[\w-]+\.config\.\w+)$/.test(file))) {
70
+ return 'chore'
71
+ }
72
+ if (files.every((file) => /^\.github\/|\.gitlab-ci\.yml$/.test(file))) {
73
+ return 'ci'
74
+ }
75
+ return 'feat'
76
+ }
77
+
78
+ function hasAiProfile() {
79
+ try {
80
+ const config = getAiConfig()
81
+ return Boolean(config.activeProfile && config.profiles?.[config.activeProfile])
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 交互式生成提交信息。
89
+ * 输入为 { files, allowAi },输出 { message } 或 { useAi: true } 或 { canceled: true }。
90
+ */
91
+ export async function promptCommitMessage(options = {}) {
92
+ const files = options.files || []
93
+ const allowAi = options.allowAi !== false && hasAiProfile()
94
+
95
+ const mode = await chooseOne('提交信息怎么写?', [
96
+ ...(allowAi ? [{ value: 'ai', label: 'AI 生成', hint: '根据改动自动生成,确认后提交' }] : []),
97
+ { value: 'guided', label: '引导填写', hint: '选类型和范围,只写一句描述' },
98
+ { value: 'manual', label: '直接输入完整信息' },
99
+ { value: 'cancel', label: '取消' }
100
+ ], 0)
101
+
102
+ if (mode === 'cancel') {
103
+ return { canceled: true }
104
+ }
105
+ if (mode === 'ai') {
106
+ return { useAi: true }
107
+ }
108
+ if (mode === 'manual') {
109
+ const message = await input('提交信息', '')
110
+ return message ? { message } : { canceled: true }
111
+ }
112
+
113
+ const defaultType = suggestType(files)
114
+ const typeIndex = Math.max(0, COMMIT_TYPES.findIndex((item) => item.value === defaultType))
115
+ const type = await chooseOne('类型', COMMIT_TYPES, typeIndex, { filter: false })
116
+
117
+ const scopes = suggestScopes(files)
118
+ let scope = ''
119
+ if (scopes.length) {
120
+ scope = await chooseOne('影响范围 (scope)', [
121
+ ...scopes.map((item) => ({ value: item, label: item })),
122
+ { value: '', label: c.gray('不写 scope') },
123
+ { value: '__custom__', label: '自己输入…' }
124
+ ], 0, { filter: false })
125
+ }
126
+ if (scope === '__custom__' || (!scopes.length && options.askScope)) {
127
+ scope = await input('scope', '')
128
+ }
129
+
130
+ const fileHint = files.length === 1 ? `更新 ${path.posix.basename(files[0])}` : ''
131
+ const subject = await input(`描述 ${c.gray(`${type}${scope ? `(${scope})` : ''}: …`)}`, fileHint)
132
+ if (!subject) {
133
+ return { canceled: true }
134
+ }
135
+
136
+ return { message: `${type}${scope ? `(${scope})` : ''}: ${subject}` }
137
+ }