@jojonax/codex-copilot 1.0.1 → 1.0.3

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.
@@ -1,13 +1,13 @@
1
1
  /**
2
- * PRD 自动检测模块
3
- * 在项目目录中自动查找 PRD 文档
2
+ * PRD auto-detection module
3
+ * Automatically finds PRD documents in the project directory
4
4
  */
5
5
 
6
6
  import { readdirSync, readFileSync, statSync } from 'fs';
7
- import { resolve, extname } from 'path';
7
+ import { resolve, extname, relative } from 'path';
8
8
  import { log } from './logger.js';
9
9
 
10
- // PRD 文件名匹配模式(优先级由高到低)
10
+ // PRD filename patterns (ordered by priority, highest first)
11
11
  const PRD_PATTERNS = [
12
12
  /prd/i,
13
13
  /product.?requirement/i,
@@ -18,9 +18,9 @@ const PRD_PATTERNS = [
18
18
  /spec/i,
19
19
  ];
20
20
 
21
- // 搜索目录(按优先级)
21
+ // Directories to search (ordered by priority)
22
22
  const SEARCH_DIRS = [
23
- '.', // 项目根目录
23
+ '.', // Project root
24
24
  'docs',
25
25
  'doc',
26
26
  'PRD',
@@ -30,16 +30,16 @@ const SEARCH_DIRS = [
30
30
  '文档',
31
31
  ];
32
32
 
33
- // 忽略目录
33
+ // Directories to ignore
34
34
  const IGNORE_DIRS = new Set([
35
35
  'node_modules', '.git', '.next', 'dist', 'build', 'vendor',
36
36
  '.codex-copilot', '.vscode', '.idea', '__pycache__', 'coverage',
37
37
  ]);
38
38
 
39
39
  /**
40
- * 自动检测当前项目中的 PRD 文件
41
- * @param {string} projectDir - 项目根目录
42
- * @returns {Array<{path: string, score: number, name: string}>} 候选 PRD 文件列表(按匹配度排序)
40
+ * Auto-detect PRD files in the project
41
+ * @param {string} projectDir - Project root directory
42
+ * @returns {Array<{path: string, score: number, name: string}>} Candidate PRD files sorted by match score
43
43
  */
44
44
  export function detectPRD(projectDir) {
45
45
  const candidates = [];
@@ -55,14 +55,14 @@ export function detectPRD(projectDir) {
55
55
  scanDir(searchPath, projectDir, candidates, 0);
56
56
  }
57
57
 
58
- // 按匹配度排序
58
+ // Sort by match score (descending)
59
59
  candidates.sort((a, b) => b.score - a.score);
60
60
 
61
61
  return candidates;
62
62
  }
63
63
 
64
64
  function scanDir(dir, projectDir, candidates, depth) {
65
- if (depth > 3) return; // 最多搜索 3
65
+ if (depth > 3) return; // Max 3 levels deep
66
66
 
67
67
  let entries;
68
68
  try {
@@ -81,15 +81,15 @@ function scanDir(dir, projectDir, candidates, depth) {
81
81
  continue;
82
82
  }
83
83
 
84
- // 只搜索 markdown 文件
84
+ // Only scan markdown files
85
85
  if (extname(entry.name).toLowerCase() !== '.md') continue;
86
86
 
87
- // 计算匹配分数
87
+ // Calculate match score
88
88
  const score = scorePRDMatch(entry.name, fullPath);
89
89
  if (score > 0) {
90
90
  candidates.push({
91
91
  path: fullPath,
92
- relativePath: fullPath.replace(projectDir + '/', ''),
92
+ relativePath: relative(projectDir, fullPath),
93
93
  name: entry.name,
94
94
  score,
95
95
  });
@@ -101,21 +101,21 @@ function scorePRDMatch(filename, fullPath) {
101
101
  let score = 0;
102
102
  const lower = filename.toLowerCase();
103
103
 
104
- // 文件名匹配
104
+ // Filename pattern matching
105
105
  for (let i = 0; i < PRD_PATTERNS.length; i++) {
106
106
  if (PRD_PATTERNS[i].test(filename)) {
107
- score += (PRD_PATTERNS.length - i) * 10; // 越靠前的模式分数越高
107
+ score += (PRD_PATTERNS.length - i) * 10; // Higher priority patterns get higher scores
108
108
  }
109
109
  }
110
110
 
111
- // 文件大小加分(PRD 通常内容较多)
111
+ // File size bonus (PRDs are typically longer)
112
112
  try {
113
113
  const stat = statSync(fullPath);
114
114
  if (stat.size > 5000) score += 5; // > 5KB
115
115
  if (stat.size > 20000) score += 5; // > 20KB
116
116
  } catch {}
117
117
 
118
- // 内容采样检测(读前 2000 字符)
118
+ // Content sampling (read first 2000 chars)
119
119
  if (score > 0) {
120
120
  try {
121
121
  const content = readFileSync(fullPath, 'utf-8').slice(0, 2000);
@@ -130,7 +130,7 @@ function scorePRDMatch(filename, fullPath) {
130
130
  }
131
131
 
132
132
  /**
133
- * 读取 PRD 文件内容
133
+ * Read PRD file content
134
134
  */
135
135
  export function readPRD(prdPath) {
136
136
  return readFileSync(prdPath, 'utf-8');
package/src/utils/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Git 操作工具模块
2
+ * Git operations utility module
3
3
  */
4
4
 
5
5
  import { execSync } from 'child_process';
@@ -17,8 +17,17 @@ function execSafe(cmd, cwd) {
17
17
  }
18
18
  }
19
19
 
20
+ // Validate branch name to prevent shell injection
21
+ function validateBranch(name) {
22
+ if (!name || typeof name !== 'string') throw new Error('Branch name cannot be empty');
23
+ if (/[;&|`$(){}[\]!\\<>"'\s]/.test(name)) {
24
+ throw new Error(`Branch name contains unsafe characters: ${name}`);
25
+ }
26
+ return name;
27
+ }
28
+
20
29
  /**
21
- * 检查 git 仓库是否干净
30
+ * Check if the git working tree is clean
22
31
  */
23
32
  export function isClean(cwd) {
24
33
  const result = exec('git status --porcelain', cwd);
@@ -26,35 +35,37 @@ export function isClean(cwd) {
26
35
  }
27
36
 
28
37
  /**
29
- * 获取当前分支名
38
+ * Get current branch name
30
39
  */
31
40
  export function currentBranch(cwd) {
32
41
  return exec('git branch --show-current', cwd);
33
42
  }
34
43
 
35
44
  /**
36
- * 获取 remote owner/repo
45
+ * Get remote owner/repo info
37
46
  */
38
47
  export function getRepoInfo(cwd) {
39
48
  const url = exec('git remote get-url origin', cwd);
40
- // 支持 https://github.com/owner/repo.git git@github.com:owner/repo.git
49
+ // Supports https://github.com/owner/repo.git and git@github.com:owner/repo.git
41
50
  const match = url.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/);
42
- if (!match) throw new Error(`无法解析 GitHub 仓库地址: ${url}`);
51
+ if (!match) throw new Error(`Cannot parse GitHub repository URL: ${url}`);
43
52
  return { owner: match[1], repo: match[2] };
44
53
  }
45
54
 
46
55
  /**
47
- * 切换到目标分支(如不存在则创建)
56
+ * Switch to target branch (create if not exists)
48
57
  */
49
58
  export function checkoutBranch(cwd, branch, baseBranch = 'main') {
59
+ validateBranch(branch);
60
+ validateBranch(baseBranch);
50
61
  const current = currentBranch(cwd);
51
62
  if (current === branch) return;
52
63
 
53
- // 先切到 base 并拉取最新
64
+ // Switch to base and pull latest
54
65
  execSafe(`git checkout ${baseBranch}`, cwd);
55
66
  execSafe(`git pull origin ${baseBranch}`, cwd);
56
67
 
57
- // 尝试切换,不存在则创建
68
+ // Try to switch, create if not exists
58
69
  const result = execSafe(`git checkout ${branch}`, cwd);
59
70
  if (!result.ok) {
60
71
  exec(`git checkout -b ${branch}`, cwd);
@@ -62,13 +73,13 @@ export function checkoutBranch(cwd, branch, baseBranch = 'main') {
62
73
  }
63
74
 
64
75
  /**
65
- * 提交所有变更
76
+ * Commit all changes
66
77
  */
67
78
  export function commitAll(cwd, message) {
68
79
  exec('git add -A', cwd);
69
80
  const result = execSafe(`git diff --cached --quiet`, cwd);
70
81
  if (result.ok) {
71
- log.dim('没有变更需要提交');
82
+ log.dim('No changes to commit');
72
83
  return false;
73
84
  }
74
85
  exec(`git commit -m ${shellEscape(message)}`, cwd);
@@ -80,16 +91,18 @@ function shellEscape(str) {
80
91
  }
81
92
 
82
93
  /**
83
- * 推送分支
94
+ * Push branch to remote
84
95
  */
85
96
  export function pushBranch(cwd, branch) {
97
+ validateBranch(branch);
86
98
  exec(`git push origin ${branch} --force-with-lease`, cwd);
87
99
  }
88
100
 
89
101
  /**
90
- * 切回主分支
102
+ * Switch back to main branch
91
103
  */
92
104
  export function checkoutMain(cwd, baseBranch = 'main') {
105
+ validateBranch(baseBranch);
93
106
  execSafe(`git checkout ${baseBranch}`, cwd);
94
107
  }
95
108
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * GitHub 操作模块 - 通过 gh CLI 操作 PR Review
2
+ * GitHub operations module - PR and Review management via gh CLI
3
3
  */
4
4
 
5
5
  import { execSync } from 'child_process';
@@ -11,7 +11,12 @@ function gh(cmd, cwd) {
11
11
 
12
12
  function ghJSON(cmd, cwd) {
13
13
  const output = gh(cmd, cwd);
14
- return output ? JSON.parse(output) : null;
14
+ if (!output) return null;
15
+ try {
16
+ return JSON.parse(output);
17
+ } catch {
18
+ return null;
19
+ }
15
20
  }
16
21
 
17
22
  function shellEscape(str) {
@@ -19,7 +24,7 @@ function shellEscape(str) {
19
24
  }
20
25
 
21
26
  /**
22
- * 检查 gh CLI 是否登录
27
+ * Check if gh CLI is authenticated
23
28
  */
24
29
  export function checkGhAuth() {
25
30
  try {
@@ -31,68 +36,71 @@ export function checkGhAuth() {
31
36
  }
32
37
 
33
38
  /**
34
- * 创建 PR
39
+ * Create a pull request
35
40
  */
36
41
  export function createPR(cwd, { title, body, base = 'main', head }) {
37
42
  const output = gh(
38
- `pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${base} --head ${head}`,
43
+ `pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${shellEscape(base)} --head ${shellEscape(head)}`,
39
44
  cwd
40
45
  );
41
- // 从输出中提取 PR URL 和编号
46
+ // Extract PR URL and number from output
42
47
  const urlMatch = output.match(/https:\/\/github\.com\/.+\/pull\/(\d+)/);
43
48
  if (urlMatch) {
44
49
  return { url: urlMatch[0], number: parseInt(urlMatch[1]) };
45
50
  }
46
- // 如果已存在
51
+ // May already exist
47
52
  const existingMatch = output.match(/already exists.+\/pull\/(\d+)/);
48
53
  if (existingMatch) {
49
54
  return { url: output, number: parseInt(existingMatch[1]) };
50
55
  }
51
- throw new Error(`创建 PR 失败: ${output}`);
56
+ throw new Error(`Failed to create PR: ${output}`);
52
57
  }
53
58
 
54
59
  /**
55
- * 获取 PR Review 列表
60
+ * Get PR review list
56
61
  */
57
62
  export function getReviews(cwd, prNumber) {
58
63
  try {
59
- return ghJSON(`api repos/{owner}/{repo}/pulls/${prNumber}/reviews`, cwd) || [];
64
+ const num = validatePRNumber(prNumber);
65
+ return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/reviews`, cwd) || [];
60
66
  } catch {
61
67
  return [];
62
68
  }
63
69
  }
64
70
 
65
71
  /**
66
- * 获取 PR Review 评论
72
+ * Get PR review comments
67
73
  */
68
74
  export function getReviewComments(cwd, prNumber) {
69
75
  try {
70
- return ghJSON(`api repos/{owner}/{repo}/pulls/${prNumber}/comments`, cwd) || [];
76
+ const num = validatePRNumber(prNumber);
77
+ return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/comments`, cwd) || [];
71
78
  } catch {
72
79
  return [];
73
80
  }
74
81
  }
75
82
 
76
83
  /**
77
- * 获取 PR issue 评论(包含 bot 评论)
84
+ * Get PR issue comments (including bot comments)
78
85
  */
79
86
  export function getIssueComments(cwd, prNumber) {
80
87
  try {
81
- return ghJSON(`api repos/{owner}/{repo}/issues/${prNumber}/comments`, cwd) || [];
88
+ const num = validatePRNumber(prNumber);
89
+ return ghJSON(`api repos/{owner}/{repo}/issues/${num}/comments`, cwd) || [];
82
90
  } catch {
83
91
  return [];
84
92
  }
85
93
  }
86
94
 
87
95
  /**
88
- * 检查最新 Review 状态
96
+ * Check the latest review state
89
97
  * @returns {'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'PENDING' | null}
90
98
  */
91
99
  export function getLatestReviewState(cwd, prNumber) {
92
100
  const reviews = getReviews(cwd, prNumber);
93
101
  if (!reviews || reviews.length === 0) return null;
94
102
 
95
- // 过滤掉 PENDING DISMISSED
103
+ // Filter out PENDING and DISMISSED
96
104
  const active = reviews.filter(r => r.state !== 'PENDING' && r.state !== 'DISMISSED');
97
105
  if (active.length === 0) return null;
98
106
 
@@ -100,14 +108,25 @@ export function getLatestReviewState(cwd, prNumber) {
100
108
  }
101
109
 
102
110
  /**
103
- * 合并 PR
111
+ * Merge a pull request
104
112
  */
105
113
  export function mergePR(cwd, prNumber, method = 'squash') {
106
- gh(`pr merge ${prNumber} --${method} --delete-branch`, cwd);
114
+ const num = validatePRNumber(prNumber);
115
+ const validMethods = ['squash', 'merge', 'rebase'];
116
+ if (!validMethods.includes(method)) {
117
+ throw new Error(`Invalid merge method: ${method}`);
118
+ }
119
+ gh(`pr merge ${num} --${method} --delete-branch`, cwd);
120
+ }
121
+
122
+ function validatePRNumber(prNumber) {
123
+ const num = parseInt(prNumber, 10);
124
+ if (isNaN(num) || num <= 0) throw new Error(`Invalid PR number: ${prNumber}`);
125
+ return num;
107
126
  }
108
127
 
109
128
  /**
110
- * 收集所有 Review 意见为结构化文本
129
+ * Collect all review feedback as structured text
111
130
  */
112
131
  export function collectReviewFeedback(cwd, prNumber) {
113
132
  const reviews = getReviews(cwd, prNumber);
@@ -116,19 +135,19 @@ export function collectReviewFeedback(cwd, prNumber) {
116
135
 
117
136
  let feedback = '';
118
137
 
119
- // Review 总评
138
+ // Review summary
120
139
  for (const r of reviews) {
121
140
  if (r.body && r.body.trim()) {
122
141
  feedback += `### Review (${r.state})\n${r.body}\n\n`;
123
142
  }
124
143
  }
125
144
 
126
- // 行内评论
145
+ // Inline comments
127
146
  for (const c of comments) {
128
147
  feedback += `### ${c.path}:L${c.line || c.original_line}\n${c.body}\n\n`;
129
148
  }
130
149
 
131
- // Bot 评论(Gemini Code Assist 等)
150
+ // Bot comments (Gemini Code Assist, etc.)
132
151
  for (const c of issueComments) {
133
152
  if (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) {
134
153
  feedback += `### Bot Review (${c.user.login})\n${c.body}\n\n`;
@@ -1,35 +1,41 @@
1
1
  /**
2
- * Logger utility - 统一终端输出样式
2
+ * Logger utility - Unified terminal output styles
3
3
  */
4
4
 
5
5
  const COLORS = {
6
- reset: '\x1b[0m',
7
6
  green: '\x1b[32m',
8
7
  yellow: '\x1b[33m',
9
8
  red: '\x1b[31m',
10
- blue: '\x1b[34m',
11
9
  cyan: '\x1b[36m',
12
10
  dim: '\x1b[2m',
13
11
  bold: '\x1b[1m',
12
+ reset: '\x1b[0m',
14
13
  };
15
14
 
16
15
  export const log = {
17
- info: (msg) => console.log(` ${COLORS.green}✔${COLORS.reset} ${msg}`),
18
- warn: (msg) => console.log(` ${COLORS.yellow}⚠${COLORS.reset} ${msg}`),
19
- error: (msg) => console.log(` ${COLORS.red}✖${COLORS.reset} ${msg}`),
20
- step: (msg) => console.log(` ${COLORS.blue}▶${COLORS.reset} ${msg}`),
21
- dim: (msg) => console.log(` ${COLORS.dim}${msg}${COLORS.reset}`),
22
- title: (msg) => console.log(` ${COLORS.bold}${COLORS.cyan}${msg}${COLORS.reset}`),
23
- blank: () => console.log(''),
16
+ info: (msg) => console.log(` ${COLORS.green}✔${COLORS.reset} ${msg}`),
17
+ warn: (msg) => console.log(` ${COLORS.yellow}⚠${COLORS.reset} ${msg}`),
18
+ error: (msg) => console.log(` ${COLORS.red}✗${COLORS.reset} ${msg}`),
19
+ step: (msg) => console.log(` ${COLORS.cyan}▶${COLORS.reset} ${msg}`),
20
+ dim: (msg) => console.log(` ${COLORS.dim}${msg}${COLORS.reset}`),
21
+ title: (msg) => console.log(` ${COLORS.bold}${msg}${COLORS.reset}`),
22
+ blank: () => console.log(''),
24
23
  };
25
24
 
26
25
  /**
27
- * 进度条显示
26
+ * Display a progress bar
28
27
  */
29
28
  export function progressBar(current, total, label = '') {
30
29
  const width = 30;
31
- const filled = Math.round((current / total) * width);
30
+ if (total <= 0) {
31
+ const bar = '░'.repeat(width);
32
+ console.log(` ${COLORS.cyan}[${bar}]${COLORS.reset} 0% ${label}`);
33
+ return;
34
+ }
35
+ const rawRatio = current / total;
36
+ const ratio = Number.isNaN(rawRatio) ? 0 : Math.max(0, Math.min(1, rawRatio));
37
+ const filled = Math.round(ratio * width);
32
38
  const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
33
- const pct = Math.round((current / total) * 100);
39
+ const pct = Math.round(ratio * 100);
34
40
  console.log(` ${COLORS.cyan}[${bar}]${COLORS.reset} ${pct}% ${label}`);
35
41
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 交互式终端工具
2
+ * Interactive terminal utilities
3
3
  */
4
4
 
5
5
  import { createInterface } from 'readline';
@@ -7,7 +7,7 @@ import { createInterface } from 'readline';
7
7
  const rl = createInterface({ input: process.stdin, output: process.stdout });
8
8
 
9
9
  /**
10
- * 提问并等待用户输入
10
+ * Ask a question and wait for user input
11
11
  */
12
12
  export function ask(question) {
13
13
  return new Promise((resolve) => {
@@ -18,7 +18,7 @@ export function ask(question) {
18
18
  }
19
19
 
20
20
  /**
21
- * 是/否确认
21
+ * Yes/No confirmation
22
22
  */
23
23
  export async function confirm(question, defaultYes = true) {
24
24
  const hint = defaultYes ? '(Y/n)' : '(y/N)';
@@ -28,21 +28,21 @@ export async function confirm(question, defaultYes = true) {
28
28
  }
29
29
 
30
30
  /**
31
- * 从列表中选择
31
+ * Select from a list
32
32
  */
33
33
  export async function select(question, choices) {
34
34
  console.log(` ${question}`);
35
35
  for (let i = 0; i < choices.length; i++) {
36
36
  console.log(` ${i + 1}. ${choices[i].label}`);
37
37
  }
38
- const answer = await ask('请输入编号:');
38
+ const answer = await ask('Enter number:');
39
39
  const idx = parseInt(answer) - 1;
40
40
  if (idx >= 0 && idx < choices.length) return choices[idx];
41
- return choices[0]; // 默认第一个
41
+ return choices[0]; // Default to first
42
42
  }
43
43
 
44
44
  /**
45
- * 关闭 readline
45
+ * Close readline interface
46
46
  */
47
47
  export function closePrompt() {
48
48
  rl.close();
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Version update checker
3
+ *
4
+ * - Checks npm registry for latest version on startup
5
+ * - 24h cache to avoid frequent network requests
6
+ * - Prints update prompt if a newer version is available
7
+ */
8
+
9
+ import { execSync } from 'child_process';
10
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
11
+ import { resolve } from 'path';
12
+ import { homedir } from 'os';
13
+ import { log } from './logger.js';
14
+
15
+ const PACKAGE_NAME = '@jojonax/codex-copilot';
16
+ const CACHE_FILE = resolve(homedir(), '.codex-copilot-update-cache.json');
17
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
18
+
19
+ /**
20
+ * Read cached version info
21
+ */
22
+ function readCache() {
23
+ try {
24
+ const data = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
25
+ if (Date.now() - data.timestamp < CACHE_TTL) {
26
+ return data.latestVersion;
27
+ }
28
+ } catch {
29
+ // Cache miss or corrupt — ignore
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Write version info to cache
36
+ */
37
+ function writeCache(latestVersion) {
38
+ try {
39
+ writeFileSync(CACHE_FILE, JSON.stringify({
40
+ latestVersion,
41
+ timestamp: Date.now(),
42
+ }));
43
+ } catch {
44
+ // Permission error — ignore silently
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Fetch latest version from npm registry
50
+ */
51
+ function fetchLatestVersion() {
52
+ try {
53
+ const output = execSync(`npm view ${PACKAGE_NAME} version`, {
54
+ encoding: 'utf-8',
55
+ stdio: ['pipe', 'pipe', 'pipe'],
56
+ timeout: 5000, // 5s timeout
57
+ }).trim();
58
+ return output || null;
59
+ } catch {
60
+ // Network error, npm not available — ignore
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Compare semantic version strings
67
+ * @returns {boolean} true if latest > current
68
+ */
69
+ function isNewer(current, latest) {
70
+ if (!current || !latest) return false;
71
+ const c = current.split('.').map(Number);
72
+ const l = latest.split('.').map(Number);
73
+ for (let i = 0; i < 3; i++) {
74
+ if ((l[i] || 0) > (c[i] || 0)) return true;
75
+ if ((l[i] || 0) < (c[i] || 0)) return false;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Check for updates and print notification
82
+ * @param {string} currentVersion - Current installed version
83
+ */
84
+ export function checkForUpdates(currentVersion) {
85
+ // 1. Check cache first
86
+ let latest = readCache();
87
+
88
+ // 2. Cache miss → fetch from npm
89
+ if (!latest) {
90
+ latest = fetchLatestVersion();
91
+ if (latest) {
92
+ writeCache(latest);
93
+ }
94
+ }
95
+
96
+ // 3. Compare and notify
97
+ if (latest && isNewer(currentVersion, latest)) {
98
+ log.blank();
99
+ log.warn(`Update available: v${currentVersion} → v${latest}`);
100
+ log.dim(` Run the following command to update:`);
101
+ log.dim(` npm install -g ${PACKAGE_NAME}@${latest}`);
102
+ }
103
+ }