@hifullmoon/aicommit 2.2.3 → 2.3.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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.3.0] - 2026-09-05
8
+
9
+ ### Added
10
+
11
+ - Added `aicommit update` for verified self-updates of regular npm-global installations, with exact-version installation, JSON output, and safeguards against updating source links or the wrong Node.js environment.
12
+
13
+ ### Changed
14
+
15
+ - Moved the cancel action to the end of sensitive-data confirmation menus to keep send choices together.
16
+
7
17
  ## [2.2.3] - 2026-09-01
8
18
 
9
19
  ### Fixed
@@ -178,7 +188,8 @@ This file lists notable user-facing changes. Internal refactors, test-only chang
178
188
  - Added file-level split planning and execution with Git-state concurrency checks.
179
189
  - Added provider presets and user/project configuration boundaries.
180
190
 
181
- [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.2.3...HEAD
191
+ [Unreleased]: https://github.com/hi-fullmoon/AICommit/compare/v2.3.0...HEAD
192
+ [2.3.0]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.3.0
182
193
  [2.2.3]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.3
183
194
  [2.2.2]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.2
184
195
  [2.2.1]: https://github.com/hi-fullmoon/AICommit/releases/tag/v2.2.1
package/README.md CHANGED
@@ -32,6 +32,14 @@ npm install --global @hifullmoon/aicommit
32
32
 
33
33
  Requires Node.js >= 18.
34
34
 
35
+ Update an npm-global installation from the configured registry:
36
+
37
+ ```bash
38
+ aicommit update
39
+ ```
40
+
41
+ The command resolves npm's `latest` dist-tag, installs that exact version, and verifies the installed manifest. It refuses to modify a source checkout, an `npm link`, or a package owned by a different active Node.js/npm environment; use the manual upgrade command from the distribution guide in those cases.
42
+
35
43
  See the bilingual [installation, upgrade, signature-verification, and rollback guide](docs/distribution.md). The npm package has an automated installation smoke test.
36
44
 
37
45
  To install a source checkout instead, run `npm install --global .` from the repository root.
@@ -287,6 +295,7 @@ Project-level configuration is treated as untrusted: it cannot change the endpoi
287
295
 
288
296
  ```bash
289
297
  aicommit setup # interactive configuration wizard
298
+ aicommit update # update the global npm installation to latest
290
299
  aicommit doctor # diagnose runtime, config, credentials, and connectivity
291
300
  aicommit config show # show the effective config with secrets redacted
292
301
  aicommit config validate # validate config without resolving credentials
@@ -376,7 +385,7 @@ Verify the registration with `whence -w _aicommit`; it should print `_aicommit:
376
385
 
377
386
  ### Machine-readable output
378
387
 
379
- Use `--output=json` for scripts and CI. Commit and split flows also require `--yes`, preventing a machine consumer from hanging on an interactive prompt. stdout contains exactly one JSON object; progress, debug details, and diagnostics go to stderr. `doctor --output=json` does not require `--yes`.
388
+ Use `--output=json` for scripts and CI. Commit and split flows also require `--yes`, preventing a machine consumer from hanging on an interactive prompt. stdout contains exactly one JSON object; progress, debug details, and diagnostics go to stderr. `doctor --output=json` and `update --output=json` do not require `--yes`.
380
389
 
381
390
  ```json
382
391
  {
package/README.zh-CN.md CHANGED
@@ -34,6 +34,14 @@ npm install --global @hifullmoon/aicommit
34
34
 
35
35
  需要 Node.js >= 18。
36
36
 
37
+ 从当前配置的 registry 更新 npm 全局安装:
38
+
39
+ ```bash
40
+ aicommit update
41
+ ```
42
+
43
+ 该命令会解析 npm 的 `latest` dist-tag,安装对应的精确版本,并校验安装后的 manifest。源码检出、`npm link`,或属于另一个 Node.js/npm 环境的安装会被拒绝;这些情况请使用分发指南中的手动升级命令。
44
+
37
45
  安装、升级、签名校验与回滚请参阅双语[分发指南](docs/distribution.md)。npm package 带有自动化安装冒烟测试。
38
46
 
39
47
  如需直接安装源码检出版本,请在仓库根目录运行 `npm install --global .`。
@@ -289,6 +297,7 @@ AICommit 不会主动发送无关的仓库文件、历史提交正文、环境
289
297
 
290
298
  ```bash
291
299
  aicommit setup # 交互式配置向导
300
+ aicommit update # 将 npm 全局安装更新到最新版
292
301
  aicommit doctor # 诊断运行时、配置、凭据和连接
293
302
  aicommit config show # 显示脱敏后的有效配置
294
303
  aicommit config validate # 校验配置,但不解析凭据
@@ -378,7 +387,7 @@ exec zsh
378
387
 
379
388
  ### 机器可读输出
380
389
 
381
- 脚本和 CI 请使用 `--output=json`。提交和 split 流程还必须使用 `--yes`,避免机器消费者卡在交互提示上。stdout 只包含一个 JSON 对象;进度、调试信息和诊断输出会写入 stderr。`doctor --output=json` 不要求 `--yes`。
390
+ 脚本和 CI 请使用 `--output=json`。提交和 split 流程还必须使用 `--yes`,避免机器消费者卡在交互提示上。stdout 只包含一个 JSON 对象;进度、调试信息和诊断输出会写入 stderr。`doctor --output=json` 和 `update --output=json` 不要求 `--yes`。
382
391
 
383
392
  ```json
384
393
  {
@@ -11,6 +11,9 @@ AICommit is distributed exclusively through npm.
11
11
  npm install --global @hifullmoon/aicommit
12
12
 
13
13
  # upgrade / 升级
14
+ aicommit update
15
+
16
+ # manual upgrade / 手动升级
14
17
  npm install --global @hifullmoon/aicommit@latest
15
18
 
16
19
  # pin or roll back / 固定或回滚
@@ -19,6 +22,10 @@ npm install --global @hifullmoon/aicommit@1.4.0
19
22
  aicommit --version
20
23
  ```
21
24
 
25
+ `aicommit update` 使用当前 `PATH` 中的 npm 和它配置的 registry,解析 `latest` dist-tag 后安装精确版本,并校验安装后的 package manifest。它仅更新当前 npm 全局根目录中的普通安装;源码检出、`npm link`、`npx` 缓存和其他 Node.js/npm 环境中的安装会被拒绝,以免更新错误的可执行文件。此时请切换到安装 AICommit 的 Node.js 环境,或使用上面的手动命令。
26
+
27
+ `aicommit update` uses the npm on the current `PATH` and its configured registry. It resolves the `latest` dist-tag, installs that exact version, and verifies the installed package manifest. It only updates a regular installation in the active npm global root; source checkouts, `npm link`, `npx` caches, and installations owned by another Node.js/npm environment are rejected to avoid updating the wrong executable. Switch to the Node.js environment that installed AICommit, or use the manual command above.
28
+
22
29
  发布工作流使用 npm Trusted Publishing,不保存长期 `NPM_TOKEN`。来自公开 GitHub 仓库的 OIDC 发布会自动携带 npm provenance。可使用当前 npm CLI 检查 registry signature 与 provenance:
23
30
 
24
31
  The release workflow uses npm Trusted Publishing without a long-lived `NPM_TOKEN`. OIDC publishing from the public GitHub repository automatically includes npm provenance. Verify registry signatures and provenance with a current npm CLI:
@@ -26,7 +33,7 @@ The release workflow uses npm Trusted Publishing without a long-lived `NPM_TOKEN
26
33
  ```bash
27
34
  workdir=$(mktemp -d)
28
35
  cd "$workdir"
29
- npm install --package-lock-only @hifullmoon/aicommit@2.2.3
36
+ npm install --package-lock-only @hifullmoon/aicommit@2.3.0
30
37
  npm audit signatures
31
38
  ```
32
39
 
@@ -26,6 +26,8 @@ JSON 模式保证 stdout 只有一个机器对象,诊断进入 stderr。`error
26
26
  | `split run --scope=all --yes` stops before API call / split 非交互在 API 前停止 | `sensitive_data` / `7` | Complete untracked scan found sensitive-looking data / 完整未跟踪扫描发现疑似敏感数据 | Review/stage intended files explicitly; do not bypass without checking the actual content |
27
27
  | Commit aborts after generation / 生成后提交中止 | `concurrent_modification` / `8` | Index/worktree changed during the protected window / 受保护窗口中 index/worktree 被修改 | Review `git status`, restore the intended snapshot, and generate again |
28
28
  | Split stopped after one or more commits / split 部分提交后停止 | reported Git failure | Hook, crash, SIGINT, or concurrent pending edit / hook、崩溃、中断或待处理文件变化 | Run `aicommit split resume`; if another Git workflow already replaced the transaction, use `aicommit split abort`(只删除恢复元数据,不改提交或工作区) |
29
+ | `aicommit update` refuses the installation / update 拒绝当前安装 | `config` / `2` | Source checkout, npm link, or another active Node/npm environment / 源码、npm link 或当前是另一套 Node/npm 环境 | Activate the Node environment that installed AICommit, or run `npm install --global @hifullmoon/aicommit@latest` manually |
30
+ | `aicommit update` cannot reach npm / update 无法访问 npm | `network` / `4` | Registry authentication, proxy, DNS, or network failure / registry 鉴权、代理、DNS 或网络失败 | Check `npm config get registry` and npm authentication/proxy settings, then retry |
29
31
  | npm provenance is absent or invalid / npm provenance 缺失或失败 | npm audit failure | Old npm CLI, non-trusted release, or wrong version / npm 过旧、非可信发布或版本错误 | Upgrade npm; run `npm audit signatures`; install only a version linked to the official workflow |
30
32
 
31
33
  If a failure remains, capture `aicommit doctor --output=json`, Node/Git versions, the error category, and redacted config sources. Never attach a diff, commit message, config file, API key, reasoning trace, or credential-helper output to a public issue.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hifullmoon/aicommit",
3
- "version": "2.2.3",
3
+ "version": "2.3.0",
4
4
  "description": "Safe, local-first AI commit message generator for Git workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -15,10 +15,12 @@ function showHelp() {
15
15
  ${chalk.bold('Usage:')}
16
16
  ${chalk.dim('$')} aicommit [path] [options]
17
17
  ${chalk.dim('$')} aicommit setup
18
+ ${chalk.dim('$')} aicommit update
18
19
  ${chalk.dim('$')} aicommit split [run|plan|apply|resume|abort] [options]
19
20
 
20
21
  ${chalk.bold('Everyday commands:')}
21
22
  setup Interactive configuration wizard
23
+ update Update the global npm installation to latest
22
24
  doctor Diagnose runtime, config, credentials, and connectivity
23
25
  split Plan and create file-level logical commits
24
26
 
@@ -92,6 +94,7 @@ function parsedDefaults(overrides = {}) {
92
94
  dryRun: false,
93
95
  yes: false,
94
96
  setup: false,
97
+ update: false,
95
98
  doctor: false,
96
99
  configAction: null,
97
100
  policyAction: null,
@@ -132,7 +135,10 @@ export function parseArgs(args = process.argv.slice(2)) {
132
135
  return parsedDefaults({ completionShell: shell });
133
136
  }
134
137
 
135
- if (args[0] === 'policy' && args[1] === 'template') {
138
+ const update = args[0] === 'update';
139
+ if (update) args = args.slice(1);
140
+
141
+ if (!update && args[0] === 'policy' && args[1] === 'template') {
136
142
  if (args.length !== 2) {
137
143
  throw fail(ERROR_CATEGORIES.CONFIG, 'policy template takes no arguments.');
138
144
  }
@@ -140,7 +146,7 @@ export function parseArgs(args = process.argv.slice(2)) {
140
146
  }
141
147
 
142
148
  let policyAction = null;
143
- if (args[0] === 'policy') {
149
+ if (!update && args[0] === 'policy') {
144
150
  policyAction = args[1];
145
151
  if (policyAction !== 'check') {
146
152
  throw fail(ERROR_CATEGORIES.CONFIG, 'policy requires one action: template or check.');
@@ -149,7 +155,7 @@ export function parseArgs(args = process.argv.slice(2)) {
149
155
  }
150
156
 
151
157
  let configAction = null;
152
- if (args[0] === 'config') {
158
+ if (!update && args[0] === 'config') {
153
159
  configAction = args[1];
154
160
  if (!['show', 'validate', 'path'].includes(configAction)) {
155
161
  throw fail(ERROR_CATEGORIES.CONFIG, 'config requires one action: show, validate, or path.');
@@ -158,7 +164,7 @@ export function parseArgs(args = process.argv.slice(2)) {
158
164
  }
159
165
 
160
166
  let splitCommand = null;
161
- if (args[0] === 'split') {
167
+ if (!update && args[0] === 'split') {
162
168
  const requestedAction = args[1];
163
169
  const actions = ['run', 'plan', 'apply', 'resume', 'abort'];
164
170
  if (!requestedAction || requestedAction.startsWith('-')) {
@@ -178,7 +184,7 @@ export function parseArgs(args = process.argv.slice(2)) {
178
184
  }
179
185
  }
180
186
 
181
- const doctor = args[0] === 'doctor';
187
+ const doctor = !update && args[0] === 'doctor';
182
188
  if (doctor) args = args.slice(1);
183
189
 
184
190
  let targetPath = null;
@@ -458,6 +464,25 @@ export function parseArgs(args = process.argv.slice(2)) {
458
464
  'doctor accepts only --provider, --model, --output, and --debug options.',
459
465
  );
460
466
  }
467
+ if (
468
+ update &&
469
+ (targetPath ||
470
+ cliLang ||
471
+ cliProvider ||
472
+ cliModel ||
473
+ cliReasoning ||
474
+ split ||
475
+ splitCommand ||
476
+ splitPlanFile ||
477
+ dryRun ||
478
+ yes ||
479
+ configAction ||
480
+ policyAction ||
481
+ policyMessageFile ||
482
+ policyRange)
483
+ ) {
484
+ throw fail(ERROR_CATEGORIES.CONFIG, 'update accepts only --output and --debug options.');
485
+ }
461
486
 
462
487
  return {
463
488
  targetPath,
@@ -473,6 +498,7 @@ export function parseArgs(args = process.argv.slice(2)) {
473
498
  dryRun,
474
499
  yes,
475
500
  setup,
501
+ update,
476
502
  doctor,
477
503
  configAction,
478
504
  policyAction,
package/src/completion.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const TOP_LEVEL = [
2
2
  'setup',
3
+ 'update',
3
4
  'doctor',
4
5
  'config',
5
6
  'policy',
@@ -51,6 +52,7 @@ _aicommit() {
51
52
  typeset -A opt_args
52
53
  commands=(
53
54
  'setup:interactive configuration wizard'
55
+ 'update:update the global npm installation'
54
56
  'doctor:diagnose configuration and connectivity'
55
57
  'config:inspect or validate configuration'
56
58
  'policy:print or enforce a repository team policy'
@@ -94,6 +96,7 @@ _aicommit "$@"
94
96
  const FISH = `# fish completion for aicommit
95
97
  complete -c aicommit -f
96
98
  complete -c aicommit -n '__fish_use_subcommand' -a setup -d 'Interactive configuration wizard'
99
+ complete -c aicommit -n '__fish_use_subcommand' -a update -d 'Update the global npm installation'
97
100
  complete -c aicommit -n '__fish_use_subcommand' -a doctor -d 'Diagnose configuration and connectivity'
98
101
  complete -c aicommit -n '__fish_use_subcommand' -a config -d 'Inspect or validate configuration'
99
102
  complete -c aicommit -n '__fish_use_subcommand' -a policy -d 'Print or enforce a repository team policy'
package/src/main.js CHANGED
@@ -50,6 +50,7 @@ import { runDoctor } from './doctor.js';
50
50
  import { runConfigCommand } from './config-command.js';
51
51
  import { generateCompletion } from './completion.js';
52
52
  import { runPolicyCommand } from './policy-command.js';
53
+ import { runUpdate } from './update.js';
53
54
  import {
54
55
  applyCommitlintPolicy,
55
56
  collectRepositoryContext,
@@ -97,6 +98,7 @@ async function runMain() {
97
98
  dryRun,
98
99
  yes,
99
100
  setup,
101
+ update,
100
102
  doctor,
101
103
  configAction,
102
104
  policyAction,
@@ -113,10 +115,12 @@ async function runMain() {
113
115
  return { exitReason: 'completion' };
114
116
  }
115
117
  const machineOutput = output === 'json';
116
- if (machineOutput && !yes && !doctor && !configAction && !policyAction) {
118
+ if (machineOutput && !yes && !doctor && !configAction && !policyAction && !update) {
117
119
  throw fail(ERROR_CATEGORIES.CONFIG, '--output=json requires --yes for commit and split flows.');
118
120
  }
119
121
 
122
+ if (update) return runUpdate({ machineOutput, debug });
123
+
120
124
  // The setup wizard is a standalone flow — no git repo, diff, or loaded
121
125
  // config required.
122
126
  if (setup) {
@@ -513,12 +517,12 @@ async function runMain() {
513
517
  description:
514
518
  'Omit sensitive files/private keys and redact detected credential values',
515
519
  },
516
- { name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
517
520
  {
518
521
  name: 'Send original diff',
519
522
  value: 'original',
520
523
  description: 'Send the unredacted content to the configured provider',
521
524
  },
525
+ { name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
522
526
  ],
523
527
  });
524
528
  if (sensitiveAction === 'cancel') {
package/src/split.js CHANGED
@@ -1518,12 +1518,12 @@ export async function splitFlow(
1518
1518
  description:
1519
1519
  'Protect model input only; sensitive files remain in the reviewed commit plan',
1520
1520
  },
1521
- { name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
1522
1521
  {
1523
1522
  name: 'Send original diff',
1524
1523
  value: 'original',
1525
1524
  description: 'Send the unredacted content to the configured provider',
1526
1525
  },
1526
+ { name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
1527
1527
  ],
1528
1528
  });
1529
1529
  if (sensitiveAction === 'cancel') {
package/src/update.js ADDED
@@ -0,0 +1,326 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import chalk from 'chalk';
8
+
9
+ import { ERROR_CATEGORIES, fail } from './errors.js';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const manifest = require('../package.json');
13
+
14
+ export const PACKAGE_NAME = manifest.name;
15
+ export const CURRENT_VERSION = manifest.version;
16
+
17
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
18
+ const SEMVER =
19
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
20
+ const NETWORK_CODES = new Set([
21
+ 'ECONNRESET',
22
+ 'ECONNREFUSED',
23
+ 'EHOSTUNREACH',
24
+ 'ENETUNREACH',
25
+ 'ENOTFOUND',
26
+ 'ETIMEDOUT',
27
+ 'EAI_AGAIN',
28
+ ]);
29
+ const PERMISSION_CODES = new Set(['EACCES', 'EPERM']);
30
+
31
+ function parseVersion(version) {
32
+ const match = SEMVER.exec(version || '');
33
+ if (!match) throw new Error(`Invalid semantic version: ${version || '(empty)'}`);
34
+ const prerelease = match[4]?.split('.') || [];
35
+ if (prerelease.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) {
36
+ throw new Error(`Invalid semantic version: ${version}`);
37
+ }
38
+ return {
39
+ major: Number(match[1]),
40
+ minor: Number(match[2]),
41
+ patch: Number(match[3]),
42
+ prerelease,
43
+ };
44
+ }
45
+
46
+ function compareIdentifiers(left, right) {
47
+ const leftNumeric = /^\d+$/.test(left);
48
+ const rightNumeric = /^\d+$/.test(right);
49
+ if (leftNumeric && rightNumeric) return Number(left) - Number(right);
50
+ if (leftNumeric) return -1;
51
+ if (rightNumeric) return 1;
52
+ if (left === right) return 0;
53
+ return left < right ? -1 : 1;
54
+ }
55
+
56
+ export function compareVersions(leftVersion, rightVersion) {
57
+ const left = parseVersion(leftVersion);
58
+ const right = parseVersion(rightVersion);
59
+ for (const field of ['major', 'minor', 'patch']) {
60
+ if (left[field] !== right[field]) return left[field] - right[field];
61
+ }
62
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
63
+ if (left.prerelease.length === 0) return 1;
64
+ if (right.prerelease.length === 0) return -1;
65
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
66
+ for (let index = 0; index < length; index += 1) {
67
+ if (left.prerelease[index] === undefined) return -1;
68
+ if (right.prerelease[index] === undefined) return 1;
69
+ const comparison = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
70
+ if (comparison !== 0) return comparison;
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ function npmInvocation(args, platform = process.platform, env = process.env) {
76
+ if (platform !== 'win32') return { command: 'npm', args };
77
+ if (!args.every((arg) => /^[0-9A-Za-z@_./:-]+$/.test(arg))) {
78
+ throw new Error('Refusing to pass an unsafe argument to npm.cmd.');
79
+ }
80
+ return {
81
+ command: env.ComSpec || env.COMSPEC || 'cmd.exe',
82
+ args: ['/d', '/c', `npm.cmd ${args.join(' ')}`],
83
+ };
84
+ }
85
+
86
+ export function runNpm(args, options = {}) {
87
+ const platform = options.platform || process.platform;
88
+ const invocation = npmInvocation(args, platform, options.env || process.env);
89
+ return spawnSync(invocation.command, invocation.args, {
90
+ encoding: 'utf8',
91
+ env: options.env || process.env,
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ timeout: args[0] === 'install' ? 300_000 : 30_000,
94
+ windowsHide: true,
95
+ });
96
+ }
97
+
98
+ function resultText(result) {
99
+ return `${result?.stderr || ''}\n${result?.stdout || ''}`;
100
+ }
101
+
102
+ function npmErrorCode(result) {
103
+ const text = resultText(result);
104
+ const npmCode = /(?:npm ERR!|npm error)\s+code\s+([A-Z0-9_-]+)/i.exec(text)?.[1];
105
+ if (npmCode) return npmCode.toUpperCase();
106
+ return /\b(EACCES|EPERM|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|ETIMEDOUT|EAI_AGAIN)\b/i
107
+ .exec(text)?.[1]
108
+ ?.toUpperCase();
109
+ }
110
+
111
+ function runCheckedNpm(args, runner, action, debug, log) {
112
+ if (debug) log(chalk.dim(` npm ${args.join(' ')}`));
113
+ let result;
114
+ try {
115
+ result = runner(args);
116
+ } catch (error) {
117
+ throw fail(ERROR_CATEGORIES.CONFIG, `Unable to start npm while ${action}.`, { cause: error });
118
+ }
119
+ if (result?.error?.code === 'ENOENT') {
120
+ throw fail(
121
+ ERROR_CATEGORIES.CONFIG,
122
+ 'npm is unavailable in PATH. Install npm or select the Node.js environment that installed aicommit.',
123
+ { cause: result.error },
124
+ );
125
+ }
126
+ if (result?.error) {
127
+ const category = NETWORK_CODES.has(result.error.code)
128
+ ? ERROR_CATEGORIES.NETWORK
129
+ : ERROR_CATEGORIES.CONFIG;
130
+ throw fail(category, `npm failed while ${action} (${result.error.code || 'unknown error'}).`, {
131
+ cause: result.error,
132
+ });
133
+ }
134
+ if (result?.status !== 0) {
135
+ const code = npmErrorCode(result);
136
+ if (PERMISSION_CODES.has(code)) {
137
+ throw fail(
138
+ ERROR_CATEGORIES.CONFIG,
139
+ `npm cannot write its global install directory (${code}). Fix npm global permissions and try again.`,
140
+ );
141
+ }
142
+ const category =
143
+ action === 'checking the registry' || NETWORK_CODES.has(code)
144
+ ? ERROR_CATEGORIES.NETWORK
145
+ : ERROR_CATEGORIES.CONFIG;
146
+ throw fail(
147
+ category,
148
+ `npm failed while ${action}${code ? ` (${code})` : ''}. ` +
149
+ (action === 'checking the registry'
150
+ ? 'Check the configured registry, authentication, proxy, and network connection.'
151
+ : `Run "npm install --global ${PACKAGE_NAME}@latest" manually for full npm diagnostics.`),
152
+ );
153
+ }
154
+ return String(result.stdout || '').trim();
155
+ }
156
+
157
+ function latestVersionFrom(output) {
158
+ let value;
159
+ try {
160
+ value = JSON.parse(output);
161
+ } catch (error) {
162
+ throw fail(ERROR_CATEGORIES.NETWORK, 'npm returned invalid metadata for the latest version.', {
163
+ cause: error,
164
+ });
165
+ }
166
+ if (typeof value !== 'string') {
167
+ throw fail(ERROR_CATEGORIES.NETWORK, 'npm returned invalid metadata for the latest version.');
168
+ }
169
+ try {
170
+ parseVersion(value);
171
+ } catch (error) {
172
+ throw fail(ERROR_CATEGORIES.NETWORK, `npm returned an invalid latest version: ${value}.`, {
173
+ cause: error,
174
+ });
175
+ }
176
+ return value;
177
+ }
178
+
179
+ function normalizedRealPath(path, platform) {
180
+ const value = realpathSync.native(path);
181
+ return platform === 'win32' ? value.toLowerCase() : value;
182
+ }
183
+
184
+ function assertActiveGlobalInstall(globalRoot, packageRoot, platform) {
185
+ const expectedPackageRoot = join(globalRoot, PACKAGE_NAME);
186
+ let expectedStat;
187
+ try {
188
+ expectedStat = lstatSync(expectedPackageRoot);
189
+ } catch (error) {
190
+ throw fail(
191
+ ERROR_CATEGORIES.CONFIG,
192
+ `Cannot safely self-update this installation. The active npm global root does not contain ${PACKAGE_NAME}. ` +
193
+ `Reinstall it with "npm install --global ${PACKAGE_NAME}@latest".`,
194
+ { cause: error },
195
+ );
196
+ }
197
+ if (expectedStat.isSymbolicLink()) {
198
+ throw fail(
199
+ ERROR_CATEGORIES.CONFIG,
200
+ 'Cannot self-update an npm-linked source checkout. Update the checkout manually, or replace the link with a global npm installation.',
201
+ );
202
+ }
203
+ let activePath;
204
+ let currentPath;
205
+ try {
206
+ activePath = normalizedRealPath(expectedPackageRoot, platform);
207
+ currentPath = normalizedRealPath(packageRoot, platform);
208
+ } catch (error) {
209
+ throw fail(ERROR_CATEGORIES.CONFIG, 'Cannot resolve the active aicommit installation.', {
210
+ cause: error,
211
+ });
212
+ }
213
+ if (activePath !== currentPath) {
214
+ throw fail(
215
+ ERROR_CATEGORIES.CONFIG,
216
+ 'Cannot safely self-update this installation because it belongs to a different Node.js or npm environment. ' +
217
+ `Activate the environment that installed it, or run "npm install --global ${PACKAGE_NAME}@latest" manually.`,
218
+ );
219
+ }
220
+ }
221
+
222
+ function installedVersion(packageRoot) {
223
+ try {
224
+ const value = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
225
+ parseVersion(value);
226
+ return value;
227
+ } catch (error) {
228
+ throw fail(
229
+ ERROR_CATEGORIES.INTERNAL,
230
+ 'The npm update finished, but the installed aicommit version could not be verified.',
231
+ { cause: error },
232
+ );
233
+ }
234
+ }
235
+
236
+ export function runUpdate(options = {}) {
237
+ const currentVersion = options.currentVersion || CURRENT_VERSION;
238
+ const packageRoot = options.packageRoot || PACKAGE_ROOT;
239
+ const platform = options.platform || process.platform;
240
+ const runner = options.runner || ((args) => runNpm(args, { platform }));
241
+ const log = options.log || console.log;
242
+ const debug = Boolean(options.debug);
243
+
244
+ parseVersion(currentVersion);
245
+ log('');
246
+ log(' ' + chalk.cyan.bold('Update aicommit'));
247
+ log(' ' + chalk.dim('─'.repeat(45)));
248
+ log(chalk.dim(' Checking the npm registry...'));
249
+
250
+ const latestVersion = latestVersionFrom(
251
+ runCheckedNpm(
252
+ ['view', PACKAGE_NAME, 'dist-tags.latest', '--json'],
253
+ runner,
254
+ 'checking the registry',
255
+ debug,
256
+ log,
257
+ ),
258
+ );
259
+ log(` Current version: ${chalk.bold(currentVersion)}`);
260
+ log(` Latest version: ${chalk.bold(latestVersion)}`);
261
+
262
+ const comparison = compareVersions(currentVersion, latestVersion);
263
+ if (comparison >= 0) {
264
+ const newer = comparison > 0;
265
+ const message = newer
266
+ ? `aicommit ${currentVersion} is newer than npm latest ${latestVersion}.`
267
+ : `aicommit ${currentVersion} is already up to date.`;
268
+ log('\n ' + chalk.green('✓') + ` ${message}\n`);
269
+ return {
270
+ message,
271
+ warnings: [],
272
+ exitReason: newer ? 'newer_than_latest' : 'up_to_date',
273
+ committed: false,
274
+ data: {
275
+ packageName: PACKAGE_NAME,
276
+ currentVersion,
277
+ latestVersion,
278
+ installedVersion: currentVersion,
279
+ updated: false,
280
+ },
281
+ };
282
+ }
283
+
284
+ const globalRoot = runCheckedNpm(
285
+ ['root', '--global'],
286
+ runner,
287
+ 'locating the global installation',
288
+ debug,
289
+ log,
290
+ );
291
+ assertActiveGlobalInstall(globalRoot, packageRoot, platform);
292
+
293
+ log(chalk.dim(`\n Installing ${PACKAGE_NAME}@${latestVersion}...`));
294
+ runCheckedNpm(
295
+ ['install', '--global', `${PACKAGE_NAME}@${latestVersion}`, '--no-audit', '--no-fund'],
296
+ runner,
297
+ 'installing the update',
298
+ debug,
299
+ log,
300
+ );
301
+
302
+ const verifiedVersion = installedVersion(packageRoot);
303
+ if (verifiedVersion !== latestVersion) {
304
+ throw fail(
305
+ ERROR_CATEGORIES.INTERNAL,
306
+ `npm completed, but aicommit is still at ${verifiedVersion} instead of ${latestVersion}. ` +
307
+ 'Check which aicommit executable is first in PATH.',
308
+ );
309
+ }
310
+
311
+ const message = `Updated aicommit from ${currentVersion} to ${verifiedVersion}.`;
312
+ log('\n ' + chalk.green('✓') + ` ${message}\n`);
313
+ return {
314
+ message,
315
+ warnings: [],
316
+ exitReason: 'updated',
317
+ committed: false,
318
+ data: {
319
+ packageName: PACKAGE_NAME,
320
+ currentVersion,
321
+ latestVersion,
322
+ installedVersion: verifiedVersion,
323
+ updated: true,
324
+ },
325
+ };
326
+ }