vibecodingmachine-cli 2025.12.1-534 → 2025.12.22-2230
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/vibecodingmachine.js +301 -12
- package/package.json +5 -2
- package/repro_open.js +13 -0
- package/reproduce_issue.js +160 -0
- package/scripts/postinstall.js +80 -0
- package/src/commands/auth.js +0 -1
- package/src/commands/auto-direct.js +455 -136
- package/src/commands/auto.js +488 -163
- package/src/commands/computers.js +306 -0
- package/src/commands/repo.js +0 -1
- package/src/commands/requirements-remote.js +308 -0
- package/src/commands/requirements.js +233 -16
- package/src/commands/status.js +0 -1
- package/src/commands/sync.js +280 -0
- package/src/utils/agent-selector.js +50 -0
- package/src/utils/antigravity-installer.js +212 -0
- package/src/utils/antigravity-js-handler.js +60 -0
- package/src/utils/asset-cleanup.js +60 -0
- package/src/utils/auth.js +232 -8
- package/src/utils/auto-mode-ansi-ui.js +0 -1
- package/src/utils/auto-mode-simple-ui.js +3 -23
- package/src/utils/compliance-check.js +166 -0
- package/src/utils/config.js +27 -1
- package/src/utils/copy-with-progress.js +167 -0
- package/src/utils/download-with-progress.js +84 -0
- package/src/utils/first-run.js +410 -0
- package/src/utils/interactive.js +1197 -391
- package/src/utils/kiro-installer.js +178 -0
- package/src/utils/persistent-header.js +1 -3
- package/src/utils/provider-registry.js +13 -4
- package/src/utils/status-card.js +2 -1
- package/src/utils/user-tracking.js +300 -0
- package/tests/requirements-navigator-buildtree-await.test.js +28 -0
|
@@ -13,9 +13,6 @@ class AutoModeSimpleUI {
|
|
|
13
13
|
this.chatCount = 0;
|
|
14
14
|
this.maxChats = null;
|
|
15
15
|
this.progress = 0;
|
|
16
|
-
this.outputLineCount = 0; // Track output lines for periodic re-render
|
|
17
|
-
this.lastCardPrintTime = Date.now(); // Track when we last printed the card
|
|
18
|
-
this.lastCardPrintLine = 0; // Track line count when we last printed the card
|
|
19
16
|
|
|
20
17
|
// Print header once
|
|
21
18
|
console.log('\n' + chalk.bold.cyan('═══════════════════════════════════════════════════════════'));
|
|
@@ -82,7 +79,7 @@ class AutoModeSimpleUI {
|
|
|
82
79
|
{ name: 'DONE', color: chalk.green }
|
|
83
80
|
];
|
|
84
81
|
|
|
85
|
-
const workflowLine = stages.map((stage,
|
|
82
|
+
const workflowLine = stages.map((stage, _index) => {
|
|
86
83
|
const icon = this.getStepIcon(stage.name, this.step);
|
|
87
84
|
const isCurrent = stage.name === this.step;
|
|
88
85
|
const stageColor = isCurrent ? currentStepColor.bold : stage.color;
|
|
@@ -109,30 +106,13 @@ class AutoModeSimpleUI {
|
|
|
109
106
|
borderColor: 'magenta',
|
|
110
107
|
backgroundColor: 'black'
|
|
111
108
|
});
|
|
112
|
-
|
|
113
|
-
// Print the card with a separator line before it for visibility
|
|
114
|
-
if (this.outputLineCount > 0) {
|
|
115
|
-
// Add separator when re-printing after output
|
|
116
|
-
console.log(chalk.gray('─'.repeat(80)));
|
|
117
|
-
}
|
|
109
|
+
|
|
118
110
|
console.log(card);
|
|
119
|
-
this.lastCardPrintTime = Date.now();
|
|
120
|
-
this.lastCardPrintLine = this.outputLineCount;
|
|
121
111
|
}
|
|
122
112
|
|
|
123
113
|
appendOutput(line) {
|
|
124
|
-
//
|
|
125
|
-
// Re-print every 20 lines of output, or if more than 5 seconds have passed
|
|
126
|
-
const linesSinceLastCard = this.outputLineCount - this.lastCardPrintLine;
|
|
127
|
-
const timeSinceLastCard = Date.now() - this.lastCardPrintTime;
|
|
128
|
-
|
|
129
|
-
if (linesSinceLastCard >= 20 || timeSinceLastCard > 5000) {
|
|
130
|
-
this.printWorkflowCard();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Increment output line counter
|
|
114
|
+
// Just print the line - no automatic card re-printing
|
|
134
115
|
if (line && line.trim()) {
|
|
135
|
-
this.outputLineCount++;
|
|
136
116
|
console.log(line);
|
|
137
117
|
}
|
|
138
118
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Compliance Check Utility
|
|
3
|
+
*
|
|
4
|
+
* Checks and prompts for compliance on CLI startup
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
let CompliancePrompt
|
|
8
|
+
try {
|
|
9
|
+
CompliancePrompt = require('vibecodingmachine-core/src/compliance/compliance-prompt')
|
|
10
|
+
} catch (e) {
|
|
11
|
+
// If core package isn't installed (local dev), gracefully skip compliance by returning true
|
|
12
|
+
CompliancePrompt = null
|
|
13
|
+
}
|
|
14
|
+
const auth = require('./auth')
|
|
15
|
+
|
|
16
|
+
async function checkCompliance() {
|
|
17
|
+
try {
|
|
18
|
+
// First check if user is authenticated
|
|
19
|
+
const isAuth = await auth.isAuthenticated()
|
|
20
|
+
if (!isAuth) {
|
|
21
|
+
// User not authenticated, skip compliance check (will be checked after login)
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Get current user
|
|
26
|
+
const user = await auth.getCurrentUser()
|
|
27
|
+
|
|
28
|
+
if (!user || !user.userId) {
|
|
29
|
+
// If we can't get user info but they're authenticated, something is wrong
|
|
30
|
+
// But don't block them - they can still use the app
|
|
31
|
+
console.warn('Warning: Unable to verify compliance status')
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If CompliancePrompt is not available (dev environment), skip compliance checks
|
|
36
|
+
if (!CompliancePrompt) {
|
|
37
|
+
if (process.env.DEBUG) console.log('Compliance checks skipped: vibecodingmachine-core not available')
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check and prompt for compliance
|
|
42
|
+
const compliancePrompt = new CompliancePrompt()
|
|
43
|
+
const status = await compliancePrompt.complianceManager.checkComplianceStatus(user.userId)
|
|
44
|
+
|
|
45
|
+
if (!status.needsAcknowledgment) {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle CLI prompting locally
|
|
50
|
+
const isCompliant = await promptCLI(user.userId, status, compliancePrompt)
|
|
51
|
+
return isCompliant
|
|
52
|
+
} catch (error) {
|
|
53
|
+
// Log error but don't block user
|
|
54
|
+
console.error('Error checking compliance:', error.message)
|
|
55
|
+
if (process.env.DEBUG) {
|
|
56
|
+
console.error(error.stack)
|
|
57
|
+
}
|
|
58
|
+
// Return true to not block the user if there's a system error
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* CLI prompt for compliance
|
|
65
|
+
*/
|
|
66
|
+
async function promptCLI(userId, status, compliancePrompt) {
|
|
67
|
+
const chalk = require('chalk')
|
|
68
|
+
const inquirer = require('inquirer')
|
|
69
|
+
|
|
70
|
+
console.log(chalk.cyan('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
71
|
+
console.log(chalk.cyan.bold(' 📋 Terms & Privacy Acknowledgment Required'))
|
|
72
|
+
console.log(chalk.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'))
|
|
73
|
+
|
|
74
|
+
console.log(chalk.white('Before continuing, please review and accept:\n'))
|
|
75
|
+
|
|
76
|
+
const questions = []
|
|
77
|
+
|
|
78
|
+
if (status.requiredActions.includes('terms')) {
|
|
79
|
+
const terms = await compliancePrompt.complianceManager.getTermsOfService()
|
|
80
|
+
|
|
81
|
+
console.log(chalk.yellow('📄 Terms of Service'))
|
|
82
|
+
console.log(chalk.gray('─'.repeat(70)))
|
|
83
|
+
console.log(formatForCLI(terms.content))
|
|
84
|
+
console.log(chalk.gray('─'.repeat(70)))
|
|
85
|
+
console.log(chalk.gray(`Version ${terms.version} | Effective: ${terms.effectiveDate}\n`))
|
|
86
|
+
|
|
87
|
+
questions.push({
|
|
88
|
+
type: 'confirm',
|
|
89
|
+
name: 'acceptTerms',
|
|
90
|
+
message: 'Do you accept the Terms of Service?',
|
|
91
|
+
default: false
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (status.requiredActions.includes('privacy')) {
|
|
96
|
+
const privacy = await compliancePrompt.complianceManager.getPrivacyPolicy()
|
|
97
|
+
|
|
98
|
+
console.log(chalk.yellow('🔒 Privacy Policy'))
|
|
99
|
+
console.log(chalk.gray('─'.repeat(70)))
|
|
100
|
+
console.log(formatForCLI(privacy.content))
|
|
101
|
+
console.log(chalk.gray('─'.repeat(70)))
|
|
102
|
+
console.log(chalk.gray(`Version ${privacy.version} | Effective: ${privacy.effectiveDate}\n`))
|
|
103
|
+
|
|
104
|
+
questions.push({
|
|
105
|
+
type: 'confirm',
|
|
106
|
+
name: 'acceptPrivacy',
|
|
107
|
+
message: 'Do you accept the Privacy Policy?',
|
|
108
|
+
default: false
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const answers = await inquirer.prompt(questions)
|
|
113
|
+
|
|
114
|
+
// Check if user accepted all required items
|
|
115
|
+
const allAccepted =
|
|
116
|
+
(!status.requiredActions.includes('terms') || answers.acceptTerms) &&
|
|
117
|
+
(!status.requiredActions.includes('privacy') || answers.acceptPrivacy)
|
|
118
|
+
|
|
119
|
+
if (!allAccepted) {
|
|
120
|
+
console.log(chalk.red('\n❌ You must accept the Terms and Privacy Policy to use VibeCodingMachine.\n'))
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Record acceptance
|
|
125
|
+
await compliancePrompt.complianceManager.recordAcknowledgment(userId, {
|
|
126
|
+
terms: answers.acceptTerms || undefined,
|
|
127
|
+
privacy: answers.acceptPrivacy || undefined
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
console.log(chalk.green('\n✅ Thank you! Your acceptance has been recorded.\n'))
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format markdown content for CLI display
|
|
136
|
+
*/
|
|
137
|
+
function formatForCLI(content) {
|
|
138
|
+
// Simple markdown formatting for terminal
|
|
139
|
+
return content
|
|
140
|
+
.split('\n')
|
|
141
|
+
.map(line => {
|
|
142
|
+
// Headers
|
|
143
|
+
if (line.startsWith('# ')) {
|
|
144
|
+
return ' ' + line.substring(2).toUpperCase()
|
|
145
|
+
}
|
|
146
|
+
if (line.startsWith('## ')) {
|
|
147
|
+
return ' ' + line.substring(3)
|
|
148
|
+
}
|
|
149
|
+
// Bold
|
|
150
|
+
line = line.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
151
|
+
// Lists
|
|
152
|
+
if (line.startsWith('- ')) {
|
|
153
|
+
return ' • ' + line.substring(2)
|
|
154
|
+
}
|
|
155
|
+
if (/^\d+\./.test(line)) {
|
|
156
|
+
return ' ' + line
|
|
157
|
+
}
|
|
158
|
+
// Regular text
|
|
159
|
+
return line ? ' ' + line : ''
|
|
160
|
+
})
|
|
161
|
+
.join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
checkCompliance
|
|
166
|
+
}
|
package/src/utils/config.js
CHANGED
|
@@ -52,13 +52,39 @@ async function setAutoConfig(autoConfig) {
|
|
|
52
52
|
await writeConfig(cfg);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const DEFAULT_STAGES = Object.freeze([
|
|
56
|
+
'PREPARE',
|
|
57
|
+
'REPRODUCE',
|
|
58
|
+
'CREATE UNIT TEST',
|
|
59
|
+
'ACT',
|
|
60
|
+
'CLEAN UP',
|
|
61
|
+
'VERIFY',
|
|
62
|
+
'RUN UNIT TESTS',
|
|
63
|
+
'DONE'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
async function getStages() {
|
|
67
|
+
const config = await getAutoConfig();
|
|
68
|
+
// Return a copy to prevent mutation of the internal array
|
|
69
|
+
return [...(config.stages || DEFAULT_STAGES)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function setStages(stages) {
|
|
73
|
+
const cfg = await readConfig();
|
|
74
|
+
cfg.auto = { ...(cfg.auto || {}), stages };
|
|
75
|
+
await writeConfig(cfg);
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
module.exports = {
|
|
56
79
|
getRepoPath,
|
|
57
80
|
setRepoPath,
|
|
58
81
|
getAutoConfig,
|
|
59
82
|
setAutoConfig,
|
|
60
83
|
readConfig,
|
|
61
|
-
writeConfig
|
|
84
|
+
writeConfig,
|
|
85
|
+
getStages,
|
|
86
|
+
setStages,
|
|
87
|
+
DEFAULT_STAGES
|
|
62
88
|
};
|
|
63
89
|
|
|
64
90
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const { spawn, execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
function progressBar(percent, width) {
|
|
6
|
+
const fill = Math.round((percent / 100) * width);
|
|
7
|
+
return '█'.repeat(fill) + '-'.repeat(Math.max(0, width - fill));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatEta(sec) {
|
|
11
|
+
if (!isFinite(sec) || sec === null) return '--:--';
|
|
12
|
+
const s = Math.max(0, Math.round(sec));
|
|
13
|
+
const m = Math.floor(s / 60);
|
|
14
|
+
const ss = s % 60;
|
|
15
|
+
return `${m}:${ss.toString().padStart(2, '0')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function tryRsync(src, dest, spinner, timeoutMs = 5 * 60 * 1000) {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which rsync', { stdio: 'ignore' });
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// choose progress option based on rsync version
|
|
25
|
+
let rsyncArgs = ['-a'];
|
|
26
|
+
try {
|
|
27
|
+
const verOut = execSync('rsync --version', { encoding: 'utf8', timeout: 2000 });
|
|
28
|
+
const m = verOut.match(/version\s+(\d+)\.(\d+)/i);
|
|
29
|
+
if (m) {
|
|
30
|
+
const major = Number(m[1]);
|
|
31
|
+
const minor = Number(m[2] || 0);
|
|
32
|
+
if (major > 3 || (major === 3 && minor >= 1)) {
|
|
33
|
+
rsyncArgs.push('--info=progress2');
|
|
34
|
+
} else {
|
|
35
|
+
rsyncArgs.push('--progress');
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
rsyncArgs.push('--progress');
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
rsyncArgs.push('--progress');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return await new Promise((resolve) => {
|
|
45
|
+
const rsync = spawn('rsync', rsyncArgs.concat([src + '/', dest]), { stdio: 'inherit' });
|
|
46
|
+
let finished = false;
|
|
47
|
+
const to = setTimeout(() => {
|
|
48
|
+
if (!finished) {
|
|
49
|
+
try { rsync.kill('SIGINT'); } catch (e) { /* ignore */ }
|
|
50
|
+
resolve(false);
|
|
51
|
+
}
|
|
52
|
+
}, timeoutMs);
|
|
53
|
+
rsync.on('close', (code) => { finished = true; clearTimeout(to); resolve(code === 0); });
|
|
54
|
+
rsync.on('error', () => { finished = true; clearTimeout(to); resolve(false); });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function tryDitto(src, dest, spinner, timeoutMs = 5 * 60 * 1000) {
|
|
59
|
+
try {
|
|
60
|
+
await fs.ensureDir(path.dirname(dest));
|
|
61
|
+
return await new Promise((resolve) => {
|
|
62
|
+
const ditto = spawn('ditto', ['-v', src, dest], { stdio: 'inherit' });
|
|
63
|
+
let finished = false;
|
|
64
|
+
const to = setTimeout(() => {
|
|
65
|
+
if (!finished) {
|
|
66
|
+
try { ditto.kill('SIGINT'); } catch (e) { /* ignore */ }
|
|
67
|
+
resolve(false);
|
|
68
|
+
}
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
ditto.on('close', (code) => { finished = true; clearTimeout(to); resolve(code === 0); });
|
|
71
|
+
ditto.on('error', () => { finished = true; clearTimeout(to); resolve(false); });
|
|
72
|
+
});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function nodeStreamCopy(src, dest, _spinner) {
|
|
79
|
+
// Determine total size (attempt du -sk fallback to recursive stat)
|
|
80
|
+
let total = 0;
|
|
81
|
+
try {
|
|
82
|
+
const out = execSync(`du -sk "${src}" | cut -f1`, { encoding: 'utf8' }).trim();
|
|
83
|
+
total = Number(out) * 1024;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// fallback: sum file sizes via traversal
|
|
86
|
+
await (async function walk(p) {
|
|
87
|
+
const entries = await fs.readdir(p);
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
const full = path.join(p, e);
|
|
90
|
+
const stat = await fs.stat(full);
|
|
91
|
+
if (stat.isDirectory()) await walk(full);
|
|
92
|
+
else total += stat.size;
|
|
93
|
+
}
|
|
94
|
+
})(src);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let copied = 0;
|
|
98
|
+
const start = Date.now();
|
|
99
|
+
const width = 30;
|
|
100
|
+
|
|
101
|
+
async function copyEntry(srcPath, dstPath) {
|
|
102
|
+
const stat = await fs.stat(srcPath);
|
|
103
|
+
if (stat.isDirectory()) {
|
|
104
|
+
await fs.ensureDir(dstPath);
|
|
105
|
+
const entries = await fs.readdir(srcPath);
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
await copyEntry(path.join(srcPath, e), path.join(dstPath, e));
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
await fs.ensureDir(path.dirname(dstPath));
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
const rs = fs.createReadStream(srcPath);
|
|
113
|
+
const ws = fs.createWriteStream(dstPath);
|
|
114
|
+
rs.on('data', (chunk) => {
|
|
115
|
+
copied += chunk.length;
|
|
116
|
+
if (total) {
|
|
117
|
+
const percent = Math.round((copied / total) * 100);
|
|
118
|
+
const mbCopied = (copied / (1024 * 1024)).toFixed(1);
|
|
119
|
+
const mbTotal = (total / (1024 * 1024)).toFixed(1);
|
|
120
|
+
const elapsed = Math.max(0.001, (Date.now() - start) / 1000);
|
|
121
|
+
const speed = copied / elapsed;
|
|
122
|
+
const eta = formatEta((total - copied) / (speed || 1));
|
|
123
|
+
const bar = progressBar(percent, width);
|
|
124
|
+
process.stdout.write(`\r\x1b[2K[${bar}] ${percent}% ${mbCopied}MB / ${mbTotal}MB ETA: ${eta}`);
|
|
125
|
+
} else {
|
|
126
|
+
process.stdout.write(`\r\x1b[2KCopying ${ (copied/(1024*1024)).toFixed(1) } MB`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
rs.on('error', reject);
|
|
130
|
+
ws.on('error', reject);
|
|
131
|
+
ws.on('close', resolve);
|
|
132
|
+
rs.pipe(ws);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await copyEntry(src, dest);
|
|
138
|
+
process.stdout.write('\n');
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function copyAppWithProgress(src, dest, opts = {}) {
|
|
143
|
+
const spinner = (opts && opts.spinner) || { start: () => {}, stop: () => {}, fail: () => {}, succeed: () => {} };
|
|
144
|
+
|
|
145
|
+
// Try rsync first
|
|
146
|
+
spinner.stop && spinner.stop();
|
|
147
|
+
console.log(`Copying ${path.basename(src)} -> ${dest} (attempting rsync...)`);
|
|
148
|
+
const okRsync = await tryRsync(src, dest, spinner);
|
|
149
|
+
if (okRsync) return true;
|
|
150
|
+
|
|
151
|
+
// Try ditto (macOS)
|
|
152
|
+
console.log('rsync failed or not available — trying ditto...');
|
|
153
|
+
const okDitto = await tryDitto(src, dest, spinner);
|
|
154
|
+
if (okDitto) return true;
|
|
155
|
+
|
|
156
|
+
// Fallback to Node streaming copy with progress
|
|
157
|
+
console.log('Falling back to node-stream copy with progress...');
|
|
158
|
+
try {
|
|
159
|
+
await nodeStreamCopy(src, dest, spinner);
|
|
160
|
+
return true;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error('node-stream copy failed:', e.message || e);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { copyAppWithProgress };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const ora = require('ora');
|
|
3
|
+
|
|
4
|
+
async function downloadWithProgress(url, dest, opts = {}) {
|
|
5
|
+
const fetch = require('node-fetch');
|
|
6
|
+
const spinner = opts.spinner || ora();
|
|
7
|
+
const label = opts.label || 'Downloading...';
|
|
8
|
+
|
|
9
|
+
spinner.start(label);
|
|
10
|
+
|
|
11
|
+
const res = await fetch(url);
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
spinner.fail(`Download failed: ${res.status} ${res.statusText}`);
|
|
14
|
+
throw new Error(`Failed to download ${url}: ${res.status}`);
|
|
15
|
+
}
|
|
16
|
+
// Stop the ora spinner so we can write an in-place progress line without conflicts
|
|
17
|
+
try { spinner.stop(); } catch (e) { /* ignore */ }
|
|
18
|
+
// Print initial progress line so user sees immediate feedback
|
|
19
|
+
try { process.stdout.write('\r\x1b[2KDownloading: 0.0 MB'); } catch (e) { /* ignore */ }
|
|
20
|
+
|
|
21
|
+
const total = Number(res.headers.get('content-length')) || 0;
|
|
22
|
+
const fileStream = fs.createWriteStream(dest);
|
|
23
|
+
|
|
24
|
+
return await new Promise((resolve, reject) => {
|
|
25
|
+
let downloaded = 0;
|
|
26
|
+
const start = Date.now();
|
|
27
|
+
let lastPercent = -1;
|
|
28
|
+
|
|
29
|
+
res.body.on('data', (chunk) => {
|
|
30
|
+
downloaded += chunk.length;
|
|
31
|
+
if (total) {
|
|
32
|
+
const percent = Math.round((downloaded / total) * 100);
|
|
33
|
+
if (percent !== lastPercent) {
|
|
34
|
+
lastPercent = percent;
|
|
35
|
+
const mbDownloaded = (downloaded / (1024 * 1024)).toFixed(1);
|
|
36
|
+
const mbTotal = (total / (1024 * 1024)).toFixed(1);
|
|
37
|
+
const elapsed = Math.max(0.001, (Date.now() - start) / 1000);
|
|
38
|
+
const speed = downloaded / elapsed; // bytes/sec
|
|
39
|
+
const etaSec = (total - downloaded) / (speed || 1);
|
|
40
|
+
const eta = formatEta(etaSec);
|
|
41
|
+
const bar = progressBar(percent, 30);
|
|
42
|
+
process.stdout.write(`\r\x1b[2K[${bar}] ${percent}% ${mbDownloaded}MB / ${mbTotal}MB ETA: ${eta}`);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
const mbDownloaded = (downloaded / (1024 * 1024)).toFixed(1);
|
|
46
|
+
process.stdout.write(`\r\x1b[2K${label} ${mbDownloaded} MB`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
res.body.on('error', (err) => {
|
|
51
|
+
spinner.fail('Download error');
|
|
52
|
+
reject(err);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
fileStream.on('error', (err) => {
|
|
56
|
+
spinner.fail('File write error');
|
|
57
|
+
reject(err);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
fileStream.on('finish', () => {
|
|
61
|
+
process.stdout.write('\n');
|
|
62
|
+
spinner.succeed('Download complete');
|
|
63
|
+
resolve();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Pipe the response body to file
|
|
67
|
+
res.body.pipe(fileStream);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function progressBar(percent, width) {
|
|
72
|
+
const fill = Math.round((percent / 100) * width);
|
|
73
|
+
return '█'.repeat(fill) + '-'.repeat(Math.max(0, width - fill));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatEta(sec) {
|
|
77
|
+
if (!isFinite(sec) || sec === null) return '--:--';
|
|
78
|
+
const s = Math.max(0, Math.round(sec));
|
|
79
|
+
const m = Math.floor(s / 60);
|
|
80
|
+
const ss = s % 60;
|
|
81
|
+
return `${m}:${ss.toString().padStart(2, '0')}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { downloadWithProgress };
|