jpm-pkg 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/LICENSE.md +21 -0
- package/README.md +148 -0
- package/bin/jpm.js +252 -0
- package/package.json +52 -0
- package/src/commands/audit.js +56 -0
- package/src/commands/config.js +59 -0
- package/src/commands/info.js +78 -0
- package/src/commands/init.js +88 -0
- package/src/commands/install.js +139 -0
- package/src/commands/list.js +103 -0
- package/src/commands/publish.js +148 -0
- package/src/commands/run.js +72 -0
- package/src/commands/search.js +41 -0
- package/src/commands/uninstall.js +48 -0
- package/src/commands/update.js +63 -0
- package/src/commands/x.js +136 -0
- package/src/core/cache.js +117 -0
- package/src/core/installer.js +316 -0
- package/src/core/lockfile.js +128 -0
- package/src/core/package-json.js +133 -0
- package/src/core/registry.js +166 -0
- package/src/core/resolver.js +248 -0
- package/src/security/audit.js +100 -0
- package/src/security/integrity.js +70 -0
- package/src/utils/config.js +138 -0
- package/src/utils/env.js +31 -0
- package/src/utils/fs.js +154 -0
- package/src/utils/http.js +232 -0
- package/src/utils/logger.js +128 -0
- package/src/utils/lru-cache.js +66 -0
- package/src/utils/progress.js +142 -0
- package/src/utils/semver.js +279 -0
- package/src/utils/system.js +39 -0
- package/src/workspace/workspace.js +126 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const zlib = require('node:zlib');
|
|
6
|
+
const logger = require('./logger');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
9
|
+
const DEFAULT_RETRIES = 3;
|
|
10
|
+
const RETRY_DELAY = 1_000;
|
|
11
|
+
const USER_AGENT = `jpm/1.0.0 node/${process.version}`;
|
|
12
|
+
|
|
13
|
+
// Connection pool via keepAlive agents
|
|
14
|
+
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 20 });
|
|
15
|
+
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 20 });
|
|
16
|
+
|
|
17
|
+
const CIRCUIT_THRESHOLD = 5;
|
|
18
|
+
const CIRCUIT_RESET_MS = 60_000;
|
|
19
|
+
const breakerState = {
|
|
20
|
+
failures: 0,
|
|
21
|
+
lastFailure: 0,
|
|
22
|
+
open: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function delay(ms) {
|
|
26
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkBreaker() {
|
|
30
|
+
if (!breakerState.open) return;
|
|
31
|
+
if (Date.now() - breakerState.lastFailure > CIRCUIT_RESET_MS) {
|
|
32
|
+
breakerState.open = false;
|
|
33
|
+
breakerState.failures = 0;
|
|
34
|
+
logger.verbose('Circuit Breaker: Resetting to CLOSED');
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error('Circuit Breaker is OPEN. Registry might be down.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* options: { method, headers, timeout, retries, retryDelay, stream }
|
|
42
|
+
* Returns: { status, headers, body } or a raw IncomingMessage if stream:true
|
|
43
|
+
*/
|
|
44
|
+
function request(url, options = {}) {
|
|
45
|
+
const {
|
|
46
|
+
method = 'GET',
|
|
47
|
+
headers = {},
|
|
48
|
+
timeout = DEFAULT_TIMEOUT,
|
|
49
|
+
retries = DEFAULT_RETRIES,
|
|
50
|
+
retryDelay = RETRY_DELAY,
|
|
51
|
+
body = null,
|
|
52
|
+
stream = false,
|
|
53
|
+
strict = false,
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
const parsed = new URL(url);
|
|
57
|
+
const isHttps = parsed.protocol === 'https:';
|
|
58
|
+
|
|
59
|
+
if (strict && !isHttps) {
|
|
60
|
+
throw new Error(`Insecure protocol "${parsed.protocol}" blocked in strict mode: ${url}`);
|
|
61
|
+
}
|
|
62
|
+
const lib = isHttps ? https : http;
|
|
63
|
+
const agent = isHttps ? httpsAgent : httpAgent;
|
|
64
|
+
|
|
65
|
+
checkBreaker();
|
|
66
|
+
|
|
67
|
+
const reqOptions = {
|
|
68
|
+
hostname: parsed.hostname,
|
|
69
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
70
|
+
path: parsed.pathname + parsed.search,
|
|
71
|
+
method,
|
|
72
|
+
agent,
|
|
73
|
+
headers: {
|
|
74
|
+
'User-Agent': USER_AGENT,
|
|
75
|
+
'Accept-Encoding': 'gzip, deflate',
|
|
76
|
+
'Accept': 'application/json',
|
|
77
|
+
...headers,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function attempt(attemptsLeft) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const req = lib.request(reqOptions, (res) => {
|
|
84
|
+
// Successful response or redirect clears one failure
|
|
85
|
+
if (res.statusCode < 400) {
|
|
86
|
+
breakerState.failures = Math.max(0, breakerState.failures - 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Follow redirects (301, 302, 307, 308)
|
|
90
|
+
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
91
|
+
logger.verbose(`Redirect → ${res.headers.location}`);
|
|
92
|
+
request(res.headers.location, options).then(resolve, reject);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (stream) { resolve(res); return; }
|
|
97
|
+
|
|
98
|
+
// Decompress
|
|
99
|
+
let pipe = res;
|
|
100
|
+
const enc = res.headers['content-encoding'];
|
|
101
|
+
if (enc === 'gzip') pipe = res.pipe(zlib.createGunzip());
|
|
102
|
+
if (enc === 'deflate') pipe = res.pipe(zlib.createInflate());
|
|
103
|
+
|
|
104
|
+
const chunks = [];
|
|
105
|
+
pipe.on('data', c => chunks.push(c));
|
|
106
|
+
pipe.on('end', () => {
|
|
107
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
108
|
+
resolve({ status: res.statusCode, headers: res.headers, body: raw });
|
|
109
|
+
});
|
|
110
|
+
pipe.on('error', (err) => {
|
|
111
|
+
recordFailure();
|
|
112
|
+
reject(err);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
req.setTimeout(timeout, () => {
|
|
117
|
+
recordFailure();
|
|
118
|
+
req.destroy(new Error(`Timeout after ${timeout}ms: ${url}`));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
req.on('error', async (err) => {
|
|
122
|
+
if (attemptsLeft > 1) {
|
|
123
|
+
logger.verbose(`Retry (${retries - attemptsLeft + 2}/${retries}) ${url}`);
|
|
124
|
+
await delay(retryDelay);
|
|
125
|
+
attempt(attemptsLeft - 1).then(resolve, reject);
|
|
126
|
+
} else {
|
|
127
|
+
recordFailure();
|
|
128
|
+
reject(err);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body));
|
|
133
|
+
req.end();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Records a failure into the circuit breaker state.
|
|
139
|
+
* @private
|
|
140
|
+
*/
|
|
141
|
+
function recordFailure() {
|
|
142
|
+
breakerState.failures++;
|
|
143
|
+
breakerState.lastFailure = Date.now();
|
|
144
|
+
if (breakerState.failures >= CIRCUIT_THRESHOLD) {
|
|
145
|
+
breakerState.open = true;
|
|
146
|
+
logger.error('Circuit Breaker: OPENING due to consistent failures');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return attempt(retries);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Convenience method for fetching and parsing JSON from a URL.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} url - Target URL
|
|
157
|
+
* @param {Object} [opts] - Request options
|
|
158
|
+
* @returns {Promise<Object>} Parsed JSON object
|
|
159
|
+
*/
|
|
160
|
+
async function getJSON(url, opts = {}) {
|
|
161
|
+
const { status, body } = await request(url, { ...opts, headers: { Accept: 'application/json', ...(opts.headers || {}) } });
|
|
162
|
+
if (status < 200 || status >= 300) {
|
|
163
|
+
const err = new Error(`HTTP ${status}: ${url}`);
|
|
164
|
+
err.status = status;
|
|
165
|
+
err.body = body;
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(body);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
throw new Error(`Invalid JSON from ${url}: ${e.message}\nBody snippet: ${body.slice(0, 200)}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Downloads a resource and pipes it to a destination stream.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} url - Source URL
|
|
179
|
+
* @param {import('node:stream').Writable} destStream - Target stream
|
|
180
|
+
* @param {Object} [opts] - Request options including onProgress callback
|
|
181
|
+
* @returns {Promise<void>}
|
|
182
|
+
*/
|
|
183
|
+
async function download(url, destStream, opts = {}) {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
request(url, { ...opts, stream: true }).then(res => {
|
|
186
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
187
|
+
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
let pipe = res;
|
|
191
|
+
const enc = res.headers['content-encoding'];
|
|
192
|
+
if (enc === 'gzip') pipe = res.pipe(zlib.createGunzip());
|
|
193
|
+
if (enc === 'deflate') pipe = res.pipe(zlib.createInflate());
|
|
194
|
+
|
|
195
|
+
const total = parseInt(res.headers['content-length'] || '0', 10);
|
|
196
|
+
let received = 0;
|
|
197
|
+
pipe.on('data', chunk => {
|
|
198
|
+
received += chunk.length;
|
|
199
|
+
opts.onProgress?.(received, total);
|
|
200
|
+
});
|
|
201
|
+
pipe.pipe(destStream);
|
|
202
|
+
destStream.on('finish', resolve);
|
|
203
|
+
destStream.on('error', reject);
|
|
204
|
+
pipe.on('error', reject);
|
|
205
|
+
}, reject);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Retrieves a list of all packages currently stored in the local cache.
|
|
211
|
+
*
|
|
212
|
+
* @returns {{ name: string, version: string }[]}
|
|
213
|
+
*/
|
|
214
|
+
function list() {
|
|
215
|
+
const root = cacheRoot();
|
|
216
|
+
if (!fs.existsSync(root)) return [];
|
|
217
|
+
const result = [];
|
|
218
|
+
for (const scopeOrName of fs.readdirSync(root)) {
|
|
219
|
+
const dir = path.join(root, scopeOrName);
|
|
220
|
+
if (!fs.statSync(dir).isDirectory()) continue;
|
|
221
|
+
for (const file of fs.readdirSync(dir)) {
|
|
222
|
+
if (file.endsWith('.tgz')) {
|
|
223
|
+
const version = file.replace('.tgz', '');
|
|
224
|
+
const name = scopeOrName.replace('__SCOPE__', '/');
|
|
225
|
+
result.push({ name, version });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { request, getJSON, download, list };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ANSI color codes — no external dep
|
|
4
|
+
const C = {
|
|
5
|
+
reset: '\x1b[0m',
|
|
6
|
+
bold: '\x1b[1m',
|
|
7
|
+
dim: '\x1b[2m',
|
|
8
|
+
red: '\x1b[31m',
|
|
9
|
+
green: '\x1b[32m',
|
|
10
|
+
yellow: '\x1b[33m',
|
|
11
|
+
blue: '\x1b[34m',
|
|
12
|
+
magenta: '\x1b[35m',
|
|
13
|
+
cyan: '\x1b[36m',
|
|
14
|
+
white: '\x1b[37m',
|
|
15
|
+
gray: '\x1b[90m',
|
|
16
|
+
bgRed: '\x1b[41m',
|
|
17
|
+
bgGreen: '\x1b[42m',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isTTY = process.stdout.isTTY;
|
|
21
|
+
const noColor = process.env.NO_COLOR || process.env.JPM_NO_COLOR;
|
|
22
|
+
|
|
23
|
+
function colorize(code, text) {
|
|
24
|
+
if (noColor || !isTTY) return text;
|
|
25
|
+
return `${code}${text}${C.reset}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Log levels: silent=0, error=1, warn=2, info=3, verbose=4, debug=5
|
|
29
|
+
let logLevel = 3;
|
|
30
|
+
|
|
31
|
+
function setLevel(level) {
|
|
32
|
+
const levels = { silent: 0, error: 1, warn: 2, info: 3, verbose: 4, debug: 5 };
|
|
33
|
+
logLevel = typeof level === 'string' ? (levels[level] ?? 3) : level;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const prefix = {
|
|
37
|
+
error: colorize(C.red, '✖ error'),
|
|
38
|
+
warn: colorize(C.yellow, '⚠ warn '),
|
|
39
|
+
info: colorize(C.cyan, 'ℹ info '),
|
|
40
|
+
success: colorize(C.green, '✔ '),
|
|
41
|
+
verbose: colorize(C.gray, '… verb '),
|
|
42
|
+
debug: colorize(C.magenta, '⬡ debug'),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const logger = {
|
|
46
|
+
setLevel,
|
|
47
|
+
|
|
48
|
+
error(...args) {
|
|
49
|
+
if (logLevel >= 1) process.stderr.write(`${prefix.error} ${args.join(' ')}\n`);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
warn(...args) {
|
|
53
|
+
if (logLevel >= 2) process.stderr.write(`${prefix.warn} ${args.join(' ')}\n`);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
info(...args) {
|
|
57
|
+
if (logLevel >= 3) process.stdout.write(`${prefix.info} ${args.join(' ')}\n`);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
success(...args) {
|
|
61
|
+
if (logLevel >= 3) process.stdout.write(`${prefix.success}${args.join(' ')}\n`);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
verbose(...args) {
|
|
65
|
+
if (logLevel >= 4) process.stdout.write(`${prefix.verbose} ${args.join(' ')}\n`);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
debug(...args) {
|
|
69
|
+
if (logLevel >= 5) process.stdout.write(`${prefix.debug} ${args.join(' ')}\n`);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Plain output — always printed unless silent
|
|
73
|
+
log(...args) {
|
|
74
|
+
if (logLevel >= 1) process.stdout.write(`${args.join(' ')}\n`);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
// Highlighted section header
|
|
78
|
+
section(title) {
|
|
79
|
+
if (logLevel >= 3) process.stdout.write(`\n${colorize(C.bold + C.cyan, title)}\n`);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
// Render a 2-column table
|
|
83
|
+
table(rows, headers) {
|
|
84
|
+
if (logLevel < 3) return;
|
|
85
|
+
const cols = headers || Object.keys(rows[0] || {});
|
|
86
|
+
const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(r[c] ?? '').length)));
|
|
87
|
+
const hr = widths.map(w => '─'.repeat(w + 2)).join('┼');
|
|
88
|
+
const fmt = (row, isHead = false) => cols
|
|
89
|
+
.map((c, i) => {
|
|
90
|
+
const cell = String(row[c] ?? '').padEnd(widths[i]);
|
|
91
|
+
return isHead ? colorize(C.bold, ` ${cell} `) : ` ${cell} `;
|
|
92
|
+
})
|
|
93
|
+
.join('│');
|
|
94
|
+
process.stdout.write(`┌${hr.replace(/┼/g, '┬')}┐\n`);
|
|
95
|
+
process.stdout.write(`│${fmt(Object.fromEntries(cols.map(c => [c, c])), true)}│\n`);
|
|
96
|
+
process.stdout.write(`├${hr}┤\n`);
|
|
97
|
+
rows.forEach(r => process.stdout.write(`│${fmt(r)}│\n`));
|
|
98
|
+
process.stdout.write(`└${hr.replace(/┼/g, '┴')}┘\n`);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// Render a dependency tree
|
|
102
|
+
tree(node, prefix_ = '', isLast = true) {
|
|
103
|
+
if (logLevel < 3) return;
|
|
104
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
105
|
+
const ext = isLast ? ' ' : '│ ';
|
|
106
|
+
const name = colorize(C.bold, node.name);
|
|
107
|
+
const ver = colorize(C.gray, `@${node.version}`);
|
|
108
|
+
process.stdout.write(`${prefix_}${prefix_ ? connector : ''}${name}${ver}\n`);
|
|
109
|
+
const children = node.dependencies || [];
|
|
110
|
+
children.forEach((child, i) => {
|
|
111
|
+
logger.tree(child, prefix_ + (prefix_ ? ext : ''), i === children.length - 1);
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// Colorize helpers exposed for other modules
|
|
116
|
+
c: {
|
|
117
|
+
red: (t) => colorize(C.red, t),
|
|
118
|
+
green: (t) => colorize(C.green, t),
|
|
119
|
+
yellow: (t) => colorize(C.yellow, t),
|
|
120
|
+
blue: (t) => colorize(C.blue, t),
|
|
121
|
+
cyan: (t) => colorize(C.cyan, t),
|
|
122
|
+
gray: (t) => colorize(C.gray, t),
|
|
123
|
+
bold: (t) => colorize(C.bold, t),
|
|
124
|
+
magenta: (t) => colorize(C.magenta, t),
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
module.exports = logger;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A simple Least Recently Used (LRU) cache implementation.
|
|
5
|
+
*/
|
|
6
|
+
class LRUCache {
|
|
7
|
+
/**
|
|
8
|
+
* @param {number} [maxSize=500] - Maximum number of items in the cache.
|
|
9
|
+
*/
|
|
10
|
+
constructor(maxSize = 500) {
|
|
11
|
+
this.maxSize = maxSize;
|
|
12
|
+
this.cache = new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Gets an item from the cache.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} key - The key to retrieve.
|
|
19
|
+
* @returns {*} The cached value, or undefined if not found.
|
|
20
|
+
*/
|
|
21
|
+
get(key) {
|
|
22
|
+
if (!this.cache.has(key)) return undefined;
|
|
23
|
+
|
|
24
|
+
// Refresh position (move to most recently used)
|
|
25
|
+
const val = this.cache.get(key);
|
|
26
|
+
this.cache.delete(key);
|
|
27
|
+
this.cache.set(key, val);
|
|
28
|
+
return val;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sets an item in the cache.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} key - The key to store.
|
|
35
|
+
* @param {*} value - The value to store.
|
|
36
|
+
*/
|
|
37
|
+
set(key, value) {
|
|
38
|
+
if (this.cache.has(key)) {
|
|
39
|
+
this.cache.delete(key);
|
|
40
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
41
|
+
// Evict least recently used (first item in Map iterator)
|
|
42
|
+
const firstKey = this.cache.keys().next().value;
|
|
43
|
+
this.cache.delete(firstKey);
|
|
44
|
+
}
|
|
45
|
+
this.cache.set(key, value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Checks if a key exists in the cache.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} key - The key to check.
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
has(key) {
|
|
55
|
+
return this.cache.has(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Clears the cache.
|
|
60
|
+
*/
|
|
61
|
+
clear() {
|
|
62
|
+
this.cache.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = LRUCache;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const isTTY = process.stdout.isTTY;
|
|
4
|
+
|
|
5
|
+
// ── Spinner ───────────────────────────────────────────────────────────────────
|
|
6
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
7
|
+
|
|
8
|
+
class Spinner {
|
|
9
|
+
constructor(text = '') {
|
|
10
|
+
this.text = text;
|
|
11
|
+
this._frame = 0;
|
|
12
|
+
this._timer = null;
|
|
13
|
+
this._active = false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
start(text) {
|
|
17
|
+
if (text) this.text = text;
|
|
18
|
+
if (!isTTY) { process.stdout.write(`${this.text}...\n`); return this; }
|
|
19
|
+
this._active = true;
|
|
20
|
+
this._render();
|
|
21
|
+
this._timer = setInterval(() => this._render(), 80);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_render() {
|
|
26
|
+
const frame = FRAMES[this._frame++ % FRAMES.length];
|
|
27
|
+
process.stdout.write(`\r\x1b[36m${frame}\x1b[0m ${this.text}\x1b[K`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
update(text) {
|
|
31
|
+
this.text = text;
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
succeed(text) {
|
|
36
|
+
this._stop();
|
|
37
|
+
const msg = text || this.text;
|
|
38
|
+
process.stdout.write(`\r\x1b[32m✔\x1b[0m ${msg}\x1b[K\n`);
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fail(text) {
|
|
43
|
+
this._stop();
|
|
44
|
+
const msg = text || this.text;
|
|
45
|
+
process.stderr.write(`\r\x1b[31m✖\x1b[0m ${msg}\x1b[K\n`);
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
warn(text) {
|
|
50
|
+
this._stop();
|
|
51
|
+
const msg = text || this.text;
|
|
52
|
+
process.stdout.write(`\r\x1b[33m⚠\x1b[0m ${msg}\x1b[K\n`);
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_stop() {
|
|
57
|
+
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
58
|
+
this._active = false;
|
|
59
|
+
if (isTTY) process.stdout.write('\r\x1b[K');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Progress Bar ─────────────────────────────────────────────────────────────
|
|
64
|
+
class ProgressBar {
|
|
65
|
+
constructor({ total = 100, width = 30, label = '' } = {}) {
|
|
66
|
+
this.total = total;
|
|
67
|
+
this.width = width;
|
|
68
|
+
this.label = label;
|
|
69
|
+
this.current = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
tick(amount = 1) {
|
|
73
|
+
this.current = Math.min(this.current + amount, this.total);
|
|
74
|
+
this._render();
|
|
75
|
+
if (this.current >= this.total) process.stdout.write('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
set(value) {
|
|
79
|
+
this.current = Math.min(value, this.total);
|
|
80
|
+
this._render();
|
|
81
|
+
if (this.current >= this.total) process.stdout.write('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_render() {
|
|
85
|
+
if (!isTTY) return;
|
|
86
|
+
const pct = this.current / this.total;
|
|
87
|
+
const filled = Math.round(this.width * pct);
|
|
88
|
+
const bar = `\x1b[32m${'█'.repeat(filled)}\x1b[90m${'░'.repeat(this.width - filled)}\x1b[0m`;
|
|
89
|
+
const pctStr = `${Math.round(pct * 100)}%`.padStart(4);
|
|
90
|
+
const cur = String(this.current).padStart(String(this.total).length);
|
|
91
|
+
process.stdout.write(`\r ${bar} ${pctStr} ${cur}/${this.total} ${this.label}\x1b[K`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Multi-line download tracker ───────────────────────────────────────────────
|
|
96
|
+
class MultiBar {
|
|
97
|
+
constructor() {
|
|
98
|
+
this._bars = new Map();
|
|
99
|
+
this._lines = 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
add(name, total) {
|
|
103
|
+
this._bars.set(name, { current: 0, total, name });
|
|
104
|
+
this._render();
|
|
105
|
+
return name;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
update(name, current) {
|
|
109
|
+
const bar = this._bars.get(name);
|
|
110
|
+
if (bar) { bar.current = current; this._render(); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
remove(name) {
|
|
114
|
+
this._bars.delete(name);
|
|
115
|
+
this._render();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_render() {
|
|
119
|
+
if (!isTTY) return;
|
|
120
|
+
// Move cursor up to overwrite previous output
|
|
121
|
+
if (this._lines > 0) process.stdout.write(`\x1b[${this._lines}A`);
|
|
122
|
+
let out = '';
|
|
123
|
+
for (const bar of this._bars.values()) {
|
|
124
|
+
const pct = bar.total ? bar.current / bar.total : 0;
|
|
125
|
+
const filled = Math.round(20 * pct);
|
|
126
|
+
const b = `\x1b[32m${'█'.repeat(filled)}\x1b[90m${'░'.repeat(20 - filled)}\x1b[0m`;
|
|
127
|
+
const name = bar.name.slice(0, 30).padEnd(30);
|
|
128
|
+
const pctStr = `${Math.round(pct * 100)}%`.padStart(4);
|
|
129
|
+
out += `\r ${b} ${pctStr} ${name}\x1b[K\n`;
|
|
130
|
+
}
|
|
131
|
+
process.stdout.write(out);
|
|
132
|
+
this._lines = this._bars.size;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
stop() {
|
|
136
|
+
if (isTTY && this._lines > 0) process.stdout.write('\n');
|
|
137
|
+
this._bars.clear();
|
|
138
|
+
this._lines = 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { Spinner, ProgressBar, MultiBar };
|