@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.
- package/bin/cli.js +33 -22
- package/package.json +1 -1
- package/src/commands/init.js +89 -86
- package/src/commands/reset.js +10 -10
- package/src/commands/run.js +145 -118
- package/src/commands/status.js +22 -15
- package/src/utils/detect-prd.js +20 -20
- package/src/utils/git.js +26 -13
- package/src/utils/github.js +41 -22
- package/src/utils/logger.js +19 -13
- package/src/utils/prompt.js +7 -7
- package/src/utils/update-check.js +103 -0
package/src/utils/detect-prd.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PRD
|
|
3
|
-
*
|
|
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
|
-
*
|
|
41
|
-
* @param {string} projectDir -
|
|
42
|
-
* @returns {Array<{path: string, score: number, name: string}>}
|
|
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; //
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
package/src/utils/github.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
56
|
+
throw new Error(`Failed to create PR: ${output}`);
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
|
-
*
|
|
60
|
+
* Get PR review list
|
|
56
61
|
*/
|
|
57
62
|
export function getReviews(cwd, prNumber) {
|
|
58
63
|
try {
|
|
59
|
-
|
|
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
|
-
*
|
|
72
|
+
* Get PR review comments
|
|
67
73
|
*/
|
|
68
74
|
export function getReviewComments(cwd, prNumber) {
|
|
69
75
|
try {
|
|
70
|
-
|
|
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
|
-
*
|
|
84
|
+
* Get PR issue comments (including bot comments)
|
|
78
85
|
*/
|
|
79
86
|
export function getIssueComments(cwd, prNumber) {
|
|
80
87
|
try {
|
|
81
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
111
|
+
* Merge a pull request
|
|
104
112
|
*/
|
|
105
113
|
export function mergePR(cwd, prNumber, method = 'squash') {
|
|
106
|
-
|
|
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
|
-
*
|
|
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
|
|
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`;
|
package/src/utils/logger.js
CHANGED
|
@@ -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:
|
|
18
|
-
warn:
|
|
19
|
-
error: (msg) => console.log(` ${COLORS.red}
|
|
20
|
-
step:
|
|
21
|
-
dim:
|
|
22
|
-
title: (msg) => console.log(` ${COLORS.bold}${
|
|
23
|
-
blank: ()
|
|
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
|
-
|
|
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(
|
|
39
|
+
const pct = Math.round(ratio * 100);
|
|
34
40
|
console.log(` ${COLORS.cyan}[${bar}]${COLORS.reset} ${pct}% ${label}`);
|
|
35
41
|
}
|
package/src/utils/prompt.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
}
|