@jojonax/codex-copilot 1.0.2 → 1.1.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/bin/cli.js +33 -22
- package/package.json +1 -1
- package/src/commands/init.js +111 -103
- package/src/commands/reset.js +25 -17
- package/src/commands/run.js +311 -210
- package/src/commands/status.js +37 -20
- package/src/utils/automator.js +279 -0
- package/src/utils/checkpoint.js +129 -0
- package/src/utils/detect-prd.js +18 -18
- package/src/utils/git.js +22 -18
- package/src/utils/github.js +157 -21
- package/src/utils/logger.js +14 -13
- package/src/utils/prompt.js +7 -7
- package/src/utils/provider.js +332 -0
- package/src/utils/update-check.js +103 -0
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,17 +17,17 @@ function execSafe(cmd, cwd) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
//
|
|
20
|
+
// Validate branch name to prevent shell injection
|
|
21
21
|
function validateBranch(name) {
|
|
22
|
-
if (!name || typeof name !== 'string') throw new Error('
|
|
23
|
-
if (/[;&|`$(){}
|
|
24
|
-
throw new Error(
|
|
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
25
|
}
|
|
26
26
|
return name;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* Check if the git working tree is clean
|
|
31
31
|
*/
|
|
32
32
|
export function isClean(cwd) {
|
|
33
33
|
const result = exec('git status --porcelain', cwd);
|
|
@@ -35,25 +35,25 @@ export function isClean(cwd) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
38
|
+
* Get current branch name
|
|
39
39
|
*/
|
|
40
40
|
export function currentBranch(cwd) {
|
|
41
41
|
return exec('git branch --show-current', cwd);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* Get remote owner/repo info
|
|
46
46
|
*/
|
|
47
47
|
export function getRepoInfo(cwd) {
|
|
48
48
|
const url = exec('git remote get-url origin', cwd);
|
|
49
|
-
//
|
|
49
|
+
// Supports https://github.com/owner/repo.git and git@github.com:owner/repo.git
|
|
50
50
|
const match = url.match(/github\.com[:/](.+?)\/(.+?)(?:\.git)?$/);
|
|
51
|
-
if (!match) throw new Error(
|
|
51
|
+
if (!match) throw new Error(`Cannot parse GitHub repository URL: ${url}`);
|
|
52
52
|
return { owner: match[1], repo: match[2] };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
56
|
+
* Switch to target branch (create if not exists)
|
|
57
57
|
*/
|
|
58
58
|
export function checkoutBranch(cwd, branch, baseBranch = 'main') {
|
|
59
59
|
validateBranch(branch);
|
|
@@ -61,11 +61,11 @@ export function checkoutBranch(cwd, branch, baseBranch = 'main') {
|
|
|
61
61
|
const current = currentBranch(cwd);
|
|
62
62
|
if (current === branch) return;
|
|
63
63
|
|
|
64
|
-
//
|
|
64
|
+
// Switch to base and pull latest
|
|
65
65
|
execSafe(`git checkout ${baseBranch}`, cwd);
|
|
66
66
|
execSafe(`git pull origin ${baseBranch}`, cwd);
|
|
67
67
|
|
|
68
|
-
//
|
|
68
|
+
// Try to switch, create if not exists
|
|
69
69
|
const result = execSafe(`git checkout ${branch}`, cwd);
|
|
70
70
|
if (!result.ok) {
|
|
71
71
|
exec(`git checkout -b ${branch}`, cwd);
|
|
@@ -73,13 +73,13 @@ export function checkoutBranch(cwd, branch, baseBranch = 'main') {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
76
|
+
* Commit all changes
|
|
77
77
|
*/
|
|
78
78
|
export function commitAll(cwd, message) {
|
|
79
79
|
exec('git add -A', cwd);
|
|
80
80
|
const result = execSafe(`git diff --cached --quiet`, cwd);
|
|
81
81
|
if (result.ok) {
|
|
82
|
-
log.dim('
|
|
82
|
+
log.dim('No changes to commit');
|
|
83
83
|
return false;
|
|
84
84
|
}
|
|
85
85
|
exec(`git commit -m ${shellEscape(message)}`, cwd);
|
|
@@ -91,15 +91,19 @@ function shellEscape(str) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
94
|
+
* Push branch to remote (handles new branches without upstream)
|
|
95
95
|
*/
|
|
96
96
|
export function pushBranch(cwd, branch) {
|
|
97
97
|
validateBranch(branch);
|
|
98
|
-
|
|
98
|
+
const result = execSafe(`git push origin ${branch} --force-with-lease`, cwd);
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
// Force-with-lease fails on new branches — fall back to regular push with upstream
|
|
101
|
+
exec(`git push -u origin ${branch}`, cwd);
|
|
102
|
+
}
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
102
|
-
*
|
|
106
|
+
* Switch back to main branch
|
|
103
107
|
*/
|
|
104
108
|
export function checkoutMain(cwd, baseBranch = 'main') {
|
|
105
109
|
validateBranch(baseBranch);
|
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';
|
|
@@ -9,6 +9,14 @@ function gh(cmd, cwd) {
|
|
|
9
9
|
return execSync(`gh ${cmd}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function ghSafe(cmd, cwd) {
|
|
13
|
+
try {
|
|
14
|
+
return { ok: true, output: gh(cmd, cwd) };
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, output: err.stderr || err.message };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function ghJSON(cmd, cwd) {
|
|
13
21
|
const output = gh(cmd, cwd);
|
|
14
22
|
if (!output) return null;
|
|
@@ -19,12 +27,20 @@ function ghJSON(cmd, cwd) {
|
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
function ghJSONSafe(cmd, cwd) {
|
|
31
|
+
try {
|
|
32
|
+
return ghJSON(cmd, cwd);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
function shellEscape(str) {
|
|
23
39
|
return `'${str.replace(/'/g, "'\\''")}'`
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/**
|
|
27
|
-
*
|
|
43
|
+
* Check if gh CLI is authenticated
|
|
28
44
|
*/
|
|
29
45
|
export function checkGhAuth() {
|
|
30
46
|
try {
|
|
@@ -36,28 +52,146 @@ export function checkGhAuth() {
|
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
|
-
*
|
|
55
|
+
* Ensure the base branch exists on remote.
|
|
56
|
+
* If not, push it first.
|
|
57
|
+
*/
|
|
58
|
+
export function ensureRemoteBranch(cwd, branch) {
|
|
59
|
+
try {
|
|
60
|
+
const result = execSync(
|
|
61
|
+
`git ls-remote --heads origin ${branch}`,
|
|
62
|
+
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
63
|
+
).trim();
|
|
64
|
+
if (!result) {
|
|
65
|
+
// Branch doesn't exist on remote, push it
|
|
66
|
+
log.info(`Base branch '${branch}' not found on remote, pushing...`);
|
|
67
|
+
execSync(`git push origin ${branch}`, {
|
|
68
|
+
cwd,
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
71
|
+
});
|
|
72
|
+
log.info(`Base branch '${branch}' pushed to remote`);
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.warn(`Failed to verify remote branch '${branch}': ${err.message}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if there are commits between base and head branches
|
|
83
|
+
*/
|
|
84
|
+
export function hasCommitsBetween(cwd, base, head) {
|
|
85
|
+
try {
|
|
86
|
+
const result = execSync(
|
|
87
|
+
`git log ${base}..${head} --oneline`,
|
|
88
|
+
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
89
|
+
).trim();
|
|
90
|
+
return result.length > 0;
|
|
91
|
+
} catch {
|
|
92
|
+
// If comparison fails (e.g., branches diverged), assume there are commits
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find an existing PR for a given head branch
|
|
99
|
+
* @returns {{ number: number, url: string } | null}
|
|
100
|
+
*/
|
|
101
|
+
export function findExistingPR(cwd, head) {
|
|
102
|
+
try {
|
|
103
|
+
const prs = ghJSON(
|
|
104
|
+
`pr list --head ${shellEscape(head)} --json number,url --limit 1`,
|
|
105
|
+
cwd
|
|
106
|
+
);
|
|
107
|
+
if (prs && prs.length > 0) {
|
|
108
|
+
return { number: prs[0].number, url: prs[0].url || '' };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a pull request with pre-checks and auto-recovery
|
|
40
118
|
*/
|
|
41
119
|
export function createPR(cwd, { title, body, base = 'main', head }) {
|
|
120
|
+
// Pre-check: ensure base branch exists on remote
|
|
121
|
+
ensureRemoteBranch(cwd, base);
|
|
122
|
+
|
|
42
123
|
const output = gh(
|
|
43
|
-
`pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${base} --head ${head}`,
|
|
124
|
+
`pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${shellEscape(base)} --head ${shellEscape(head)}`,
|
|
44
125
|
cwd
|
|
45
126
|
);
|
|
46
|
-
//
|
|
127
|
+
// Extract PR URL and number from output
|
|
47
128
|
const urlMatch = output.match(/https:\/\/github\.com\/.+\/pull\/(\d+)/);
|
|
48
129
|
if (urlMatch) {
|
|
49
130
|
return { url: urlMatch[0], number: parseInt(urlMatch[1]) };
|
|
50
131
|
}
|
|
51
|
-
//
|
|
132
|
+
// May already exist
|
|
52
133
|
const existingMatch = output.match(/already exists.+\/pull\/(\d+)/);
|
|
53
134
|
if (existingMatch) {
|
|
54
135
|
return { url: output, number: parseInt(existingMatch[1]) };
|
|
55
136
|
}
|
|
56
|
-
throw new Error(
|
|
137
|
+
throw new Error(`Failed to create PR: ${output}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create PR with full auto-recovery: pre-checks → create → fallback to find existing
|
|
142
|
+
* @returns {{ number: number, url: string }}
|
|
143
|
+
*/
|
|
144
|
+
export function createPRWithRecovery(cwd, { title, body, base = 'main', head }) {
|
|
145
|
+
// Step 1: Try to create the PR
|
|
146
|
+
try {
|
|
147
|
+
return createPR(cwd, { title, body, base, head });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.warn(`PR creation failed: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Step 2: Auto-find existing PR for this branch
|
|
153
|
+
log.info('Searching for existing PR...');
|
|
154
|
+
const existing = findExistingPR(cwd, head);
|
|
155
|
+
if (existing) {
|
|
156
|
+
log.info(`Found existing PR #${existing.number}`);
|
|
157
|
+
return existing;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Step 3: Check if there are actually commits to create a PR for
|
|
161
|
+
if (!hasCommitsBetween(cwd, base, head)) {
|
|
162
|
+
log.warn('No commits between base and head branch');
|
|
163
|
+
log.info('This may be a new repo or empty branch. Attempting to push base branch and retry...');
|
|
164
|
+
|
|
165
|
+
// Ensure both branches are pushed
|
|
166
|
+
ensureRemoteBranch(cwd, base);
|
|
167
|
+
ensureRemoteBranch(cwd, head);
|
|
168
|
+
|
|
169
|
+
// Retry PR creation
|
|
170
|
+
try {
|
|
171
|
+
return createPR(cwd, { title, body, base, head });
|
|
172
|
+
} catch (retryErr) {
|
|
173
|
+
log.warn(`PR retry also failed: ${retryErr.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Last resort: try to find PR again
|
|
177
|
+
const retryExisting = findExistingPR(cwd, head);
|
|
178
|
+
if (retryExisting) {
|
|
179
|
+
log.info(`Found existing PR #${retryExisting.number}`);
|
|
180
|
+
return retryExisting;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Step 4: If all else fails, throw with actionable message
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Cannot create or find PR for branch '${head}'. ` +
|
|
187
|
+
`Possible causes: base branch '${base}' may not exist on remote, ` +
|
|
188
|
+
`or there are no commits between '${base}' and '${head}'. ` +
|
|
189
|
+
`Try: git push origin ${base} && git push origin ${head}`
|
|
190
|
+
);
|
|
57
191
|
}
|
|
58
192
|
|
|
59
193
|
/**
|
|
60
|
-
*
|
|
194
|
+
* Get PR review list
|
|
61
195
|
*/
|
|
62
196
|
export function getReviews(cwd, prNumber) {
|
|
63
197
|
try {
|
|
@@ -69,7 +203,7 @@ export function getReviews(cwd, prNumber) {
|
|
|
69
203
|
}
|
|
70
204
|
|
|
71
205
|
/**
|
|
72
|
-
*
|
|
206
|
+
* Get PR review comments
|
|
73
207
|
*/
|
|
74
208
|
export function getReviewComments(cwd, prNumber) {
|
|
75
209
|
try {
|
|
@@ -81,7 +215,7 @@ export function getReviewComments(cwd, prNumber) {
|
|
|
81
215
|
}
|
|
82
216
|
|
|
83
217
|
/**
|
|
84
|
-
*
|
|
218
|
+
* Get PR issue comments (including bot comments)
|
|
85
219
|
*/
|
|
86
220
|
export function getIssueComments(cwd, prNumber) {
|
|
87
221
|
try {
|
|
@@ -93,14 +227,14 @@ export function getIssueComments(cwd, prNumber) {
|
|
|
93
227
|
}
|
|
94
228
|
|
|
95
229
|
/**
|
|
96
|
-
*
|
|
230
|
+
* Check the latest review state
|
|
97
231
|
* @returns {'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'PENDING' | null}
|
|
98
232
|
*/
|
|
99
233
|
export function getLatestReviewState(cwd, prNumber) {
|
|
100
234
|
const reviews = getReviews(cwd, prNumber);
|
|
101
235
|
if (!reviews || reviews.length === 0) return null;
|
|
102
236
|
|
|
103
|
-
//
|
|
237
|
+
// Filter out PENDING and DISMISSED
|
|
104
238
|
const active = reviews.filter(r => r.state !== 'PENDING' && r.state !== 'DISMISSED');
|
|
105
239
|
if (active.length === 0) return null;
|
|
106
240
|
|
|
@@ -108,25 +242,25 @@ export function getLatestReviewState(cwd, prNumber) {
|
|
|
108
242
|
}
|
|
109
243
|
|
|
110
244
|
/**
|
|
111
|
-
*
|
|
245
|
+
* Merge a pull request
|
|
112
246
|
*/
|
|
113
247
|
export function mergePR(cwd, prNumber, method = 'squash') {
|
|
114
248
|
const num = validatePRNumber(prNumber);
|
|
115
249
|
const validMethods = ['squash', 'merge', 'rebase'];
|
|
116
250
|
if (!validMethods.includes(method)) {
|
|
117
|
-
throw new Error(
|
|
251
|
+
throw new Error(`Invalid merge method: ${method}`);
|
|
118
252
|
}
|
|
119
253
|
gh(`pr merge ${num} --${method} --delete-branch`, cwd);
|
|
120
254
|
}
|
|
121
255
|
|
|
122
256
|
function validatePRNumber(prNumber) {
|
|
123
257
|
const num = parseInt(prNumber, 10);
|
|
124
|
-
if (isNaN(num) || num <= 0) throw new Error(
|
|
258
|
+
if (isNaN(num) || num <= 0) throw new Error(`Invalid PR number: ${prNumber}`);
|
|
125
259
|
return num;
|
|
126
260
|
}
|
|
127
261
|
|
|
128
262
|
/**
|
|
129
|
-
*
|
|
263
|
+
* Collect all review feedback as structured text
|
|
130
264
|
*/
|
|
131
265
|
export function collectReviewFeedback(cwd, prNumber) {
|
|
132
266
|
const reviews = getReviews(cwd, prNumber);
|
|
@@ -135,19 +269,19 @@ export function collectReviewFeedback(cwd, prNumber) {
|
|
|
135
269
|
|
|
136
270
|
let feedback = '';
|
|
137
271
|
|
|
138
|
-
// Review
|
|
272
|
+
// Review summary
|
|
139
273
|
for (const r of reviews) {
|
|
140
274
|
if (r.body && r.body.trim()) {
|
|
141
275
|
feedback += `### Review (${r.state})\n${r.body}\n\n`;
|
|
142
276
|
}
|
|
143
277
|
}
|
|
144
278
|
|
|
145
|
-
//
|
|
279
|
+
// Inline comments
|
|
146
280
|
for (const c of comments) {
|
|
147
281
|
feedback += `### ${c.path}:L${c.line || c.original_line}\n${c.body}\n\n`;
|
|
148
282
|
}
|
|
149
283
|
|
|
150
|
-
// Bot
|
|
284
|
+
// Bot comments (Gemini Code Assist, etc.)
|
|
151
285
|
for (const c of issueComments) {
|
|
152
286
|
if (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) {
|
|
153
287
|
feedback += `### Bot Review (${c.user.login})\n${c.body}\n\n`;
|
|
@@ -158,6 +292,8 @@ export function collectReviewFeedback(cwd, prNumber) {
|
|
|
158
292
|
}
|
|
159
293
|
|
|
160
294
|
export const github = {
|
|
161
|
-
checkGhAuth, createPR,
|
|
162
|
-
|
|
295
|
+
checkGhAuth, createPR, createPRWithRecovery, findExistingPR,
|
|
296
|
+
ensureRemoteBranch, hasCommitsBetween,
|
|
297
|
+
getReviews, getReviewComments, getIssueComments,
|
|
298
|
+
getLatestReviewState, mergePR, collectReviewFeedback,
|
|
163
299
|
};
|
package/src/utils/logger.js
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
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;
|
|
@@ -33,8 +32,10 @@ export function progressBar(current, total, label = '') {
|
|
|
33
32
|
console.log(` ${COLORS.cyan}[${bar}]${COLORS.reset} 0% ${label}`);
|
|
34
33
|
return;
|
|
35
34
|
}
|
|
36
|
-
const
|
|
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);
|
|
37
38
|
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
38
|
-
const pct = Math.round(
|
|
39
|
+
const pct = Math.round(ratio * 100);
|
|
39
40
|
console.log(` ${COLORS.cyan}[${bar}]${COLORS.reset} ${pct}% ${label}`);
|
|
40
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();
|