@compressius/cmx 0.2.23 → 0.2.24
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/cmx.js +9 -1
- package/package.json +1 -1
- package/scripts/install.js +208 -21
package/bin/cmx.js
CHANGED
|
@@ -18,7 +18,15 @@ if (!os || !arch) {
|
|
|
18
18
|
async function main() {
|
|
19
19
|
const extension = os === 'windows' ? '.exe' : '';
|
|
20
20
|
const binary = path.join(__dirname, '..', 'platform', `cmx-${os}-${arch}${extension}`);
|
|
21
|
-
if (!fs.existsSync(binary))
|
|
21
|
+
if (!fs.existsSync(binary)) {
|
|
22
|
+
// The download screen settles before the requested command runs; a failed
|
|
23
|
+
// download exits without ever spawning a partial or missing binary.
|
|
24
|
+
try {
|
|
25
|
+
await require('../scripts/install').install();
|
|
26
|
+
} catch {
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' });
|
|
24
32
|
if (result.error) {
|
package/package.json
CHANGED
package/scripts/install.js
CHANGED
|
@@ -4,6 +4,7 @@ const crypto = require('node:crypto');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const https = require('node:https');
|
|
6
6
|
const path = require('node:path');
|
|
7
|
+
const readline = require('node:readline');
|
|
7
8
|
|
|
8
9
|
const packageJson = require('../package.json');
|
|
9
10
|
const platforms = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
|
|
@@ -19,13 +20,149 @@ const asset = `cmx-${os}-${arch}${os === 'windows' ? '.exe' : ''}`;
|
|
|
19
20
|
const tag = `v${packageJson.version}`;
|
|
20
21
|
const releaseBase = `https://github.com/compressius/cmx/releases/download/${tag}`;
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
24
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
25
|
+
const ACCENT = '38;2;217;119;87';
|
|
26
|
+
const ACCENT_LIGHT = '38;2;240;169;142';
|
|
27
|
+
|
|
28
|
+
function formatBytes(bytes) {
|
|
29
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
|
|
30
|
+
if (bytes < 1024) return `${Math.floor(bytes)} B`;
|
|
31
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
32
|
+
let value = bytes;
|
|
33
|
+
let unit = -1;
|
|
34
|
+
do {
|
|
35
|
+
value /= 1024;
|
|
36
|
+
unit += 1;
|
|
37
|
+
} while (value >= 1024 && unit < units.length - 1);
|
|
38
|
+
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// renderBar draws only received bytes against a reliable total. An unknown
|
|
42
|
+
// total yields an empty track; callers must pair it with byte counts instead
|
|
43
|
+
// of inventing a percentage.
|
|
44
|
+
function renderBar(received, total, width) {
|
|
45
|
+
if (!(total > 0)) return '░'.repeat(width);
|
|
46
|
+
const ratio = Math.min(1, Math.max(0, received / total));
|
|
47
|
+
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
|
|
48
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function terminalSupportsAnimation(stream) {
|
|
52
|
+
if (!stream.isTTY) return false;
|
|
53
|
+
if (process.env.TERM === 'dumb') return false;
|
|
54
|
+
if (process.platform === 'win32' && !process.env.WT_SESSION && !process.env.ANSICON && !process.env.TERM) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// createProgress renders the local download screen. Interactive terminals get
|
|
61
|
+
// an in-place branded block; piped output gets plain truthful lines with no
|
|
62
|
+
// escape codes. Progress is stderr-only so command output stays clean.
|
|
63
|
+
function createProgress(stream) {
|
|
64
|
+
const interactive = terminalSupportsAnimation(stream);
|
|
65
|
+
const color = interactive && !process.env.NO_COLOR;
|
|
66
|
+
const paint = (code, value) => (color ? `\x1b[${code}m${value}\x1b[0m` : value);
|
|
67
|
+
const accent = (value) => paint(ACCENT, value);
|
|
68
|
+
const dim = (value) => paint('2', value);
|
|
69
|
+
const shown = () => stream.write(SHOW_CURSOR);
|
|
70
|
+
|
|
71
|
+
let drawn = 0;
|
|
72
|
+
let status = '';
|
|
73
|
+
let closed = false;
|
|
74
|
+
const showOnExit = () => shown();
|
|
75
|
+
|
|
76
|
+
if (interactive) {
|
|
77
|
+
stream.write(HIDE_CURSOR);
|
|
78
|
+
process.once('exit', showOnExit);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const header = `${accent('CMX')} ${dim('· Compressius Maximus')}`;
|
|
82
|
+
|
|
83
|
+
function erase() {
|
|
84
|
+
if (drawn === 0) return;
|
|
85
|
+
readline.moveCursor(stream, 0, -drawn);
|
|
86
|
+
for (let line = 0; line < drawn; line += 1) {
|
|
87
|
+
readline.clearLine(stream, 0);
|
|
88
|
+
readline.cursorTo(stream, 0);
|
|
89
|
+
stream.write('\n');
|
|
90
|
+
}
|
|
91
|
+
readline.moveCursor(stream, 0, -drawn);
|
|
92
|
+
readline.cursorTo(stream, 0);
|
|
93
|
+
drawn = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function draw(lines) {
|
|
97
|
+
if (!interactive) return;
|
|
98
|
+
erase();
|
|
99
|
+
for (const line of lines) stream.write(`${line}\n`);
|
|
100
|
+
drawn = lines.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function settle(lines) {
|
|
104
|
+
if (!interactive) return;
|
|
105
|
+
erase();
|
|
106
|
+
for (const line of lines) stream.write(`${line}\n`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function barLine(received, total) {
|
|
110
|
+
const columns = stream.columns || 80;
|
|
111
|
+
const width = Math.max(12, Math.min(36, columns - 34));
|
|
112
|
+
const bar = accent(renderBar(received, total, width).replace(/░+$/, (empty) => dim(empty)));
|
|
113
|
+
if (total > 0) {
|
|
114
|
+
const percent = Math.floor(Math.min(1, received / total) * 100);
|
|
115
|
+
return ` ${bar} ${String(percent).padStart(3)}% ${formatBytes(received)} / ${formatBytes(total)}`;
|
|
116
|
+
}
|
|
117
|
+
return ` ${bar} ${formatBytes(received)} downloaded`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
begin(label) {
|
|
122
|
+
status = label;
|
|
123
|
+
if (interactive) draw([header, status]);
|
|
124
|
+
else stream.write(`${label}\n`);
|
|
125
|
+
},
|
|
126
|
+
update(received, total) {
|
|
127
|
+
if (!interactive) return;
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (received < total && now - (this._paintedAt || 0) < 80) return;
|
|
130
|
+
this._paintedAt = now;
|
|
131
|
+
draw([header, status, barLine(received, total)]);
|
|
132
|
+
},
|
|
133
|
+
stage(label) {
|
|
134
|
+
status = label;
|
|
135
|
+
if (interactive) draw([header, dim(label)]);
|
|
136
|
+
else stream.write(`${label}\n`);
|
|
137
|
+
},
|
|
138
|
+
done(message) {
|
|
139
|
+
settle([header, ` ${accent('✓')} ${message}`]);
|
|
140
|
+
if (!interactive) stream.write(`✓ ${message}\n`);
|
|
141
|
+
},
|
|
142
|
+
fail(message) {
|
|
143
|
+
const line = ` ${paint(ACCENT_LIGHT, '✗')} ${message}`;
|
|
144
|
+
settle([header, line]);
|
|
145
|
+
if (!interactive) stream.write(`${message}\n`);
|
|
146
|
+
},
|
|
147
|
+
cancel(message) {
|
|
148
|
+
this.fail(message);
|
|
149
|
+
},
|
|
150
|
+
close() {
|
|
151
|
+
if (closed) return;
|
|
152
|
+
closed = true;
|
|
153
|
+
process.removeListener('exit', showOnExit);
|
|
154
|
+
if (interactive) shown();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function download(url, redirects = 0, onProgress, client = https) {
|
|
23
160
|
if (redirects > 5) return Promise.reject(new Error(`Too many redirects downloading ${url}.`));
|
|
24
161
|
return new Promise((resolve, reject) => {
|
|
25
|
-
const request =
|
|
162
|
+
const request = client.get(url, { headers: { 'User-Agent': 'cmx-npm-installer' } }, (response) => {
|
|
26
163
|
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
27
164
|
response.resume();
|
|
28
|
-
resolve(download(new URL(response.headers.location, url), redirects + 1));
|
|
165
|
+
resolve(download(new URL(response.headers.location, url), redirects + 1, onProgress, client));
|
|
29
166
|
return;
|
|
30
167
|
}
|
|
31
168
|
if (response.statusCode !== 200) {
|
|
@@ -33,8 +170,16 @@ function download(url, redirects = 0) {
|
|
|
33
170
|
reject(new Error(`Download failed for ${url}: HTTP ${response.statusCode}.`));
|
|
34
171
|
return;
|
|
35
172
|
}
|
|
173
|
+
// A missing Content-Length is a valid unknown length: report real bytes
|
|
174
|
+
// and never fabricate a percentage for it.
|
|
175
|
+
const total = Number(response.headers['content-length']) || 0;
|
|
176
|
+
let received = 0;
|
|
36
177
|
const chunks = [];
|
|
37
|
-
response.on('data', (chunk) =>
|
|
178
|
+
response.on('data', (chunk) => {
|
|
179
|
+
received += chunk.length;
|
|
180
|
+
chunks.push(chunk);
|
|
181
|
+
if (onProgress) onProgress(received, total);
|
|
182
|
+
});
|
|
38
183
|
response.on('end', () => resolve(Buffer.concat(chunks)));
|
|
39
184
|
});
|
|
40
185
|
request.setTimeout(60_000, () => request.destroy(new Error(`Timed out downloading ${url}.`)));
|
|
@@ -50,27 +195,69 @@ function checksumFor(sums) {
|
|
|
50
195
|
return match[1].toLowerCase();
|
|
51
196
|
}
|
|
52
197
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
198
|
+
// writeExecutable stages the verified binary and renames it into place so an
|
|
199
|
+
// interrupted install can never leave a partial executable behind.
|
|
200
|
+
function writeExecutable(destination, contents) {
|
|
201
|
+
const pending = `${destination}.download-${process.pid}`;
|
|
202
|
+
try {
|
|
203
|
+
fs.writeFileSync(pending, contents, { mode: 0o755 });
|
|
204
|
+
if (os !== 'windows') fs.chmodSync(pending, 0o755);
|
|
205
|
+
try {
|
|
206
|
+
fs.renameSync(pending, destination);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (!fs.existsSync(destination)) throw error;
|
|
209
|
+
fs.rmSync(destination, { force: true });
|
|
210
|
+
fs.renameSync(pending, destination);
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
fs.rmSync(pending, { force: true });
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function install(options = {}) {
|
|
219
|
+
const stream = options.stream || process.stderr;
|
|
220
|
+
const display = createProgress(stream);
|
|
221
|
+
const onInterrupt = () => {
|
|
222
|
+
display.cancel('Cancelled; CMX was not installed.');
|
|
223
|
+
process.exit(130);
|
|
224
|
+
};
|
|
225
|
+
process.once('SIGINT', onInterrupt);
|
|
226
|
+
try {
|
|
227
|
+
let received = 0;
|
|
228
|
+
let total = 0;
|
|
229
|
+
display.begin(`Downloading ${asset} ${tag}`);
|
|
230
|
+
const [binary, sums] = await Promise.all([
|
|
231
|
+
download(`${releaseBase}/${asset}`, 0, (done, declared) => {
|
|
232
|
+
received = done;
|
|
233
|
+
total = declared;
|
|
234
|
+
display.update(done, declared);
|
|
235
|
+
}),
|
|
236
|
+
download(`${releaseBase}/SHA256SUMS`),
|
|
237
|
+
]);
|
|
238
|
+
display.stage('Verifying SHA-256 checksum…');
|
|
239
|
+
const actual = crypto.createHash('sha256').update(binary).digest('hex');
|
|
240
|
+
if (actual !== checksumFor(sums)) throw new Error(`Checksum mismatch for ${asset}; CMX was not installed.`);
|
|
241
|
+
|
|
242
|
+
display.stage('Installing…');
|
|
243
|
+
const platformDir = path.join(__dirname, '..', 'platform');
|
|
244
|
+
fs.mkdirSync(platformDir, { recursive: true });
|
|
245
|
+
writeExecutable(path.join(platformDir, asset), binary);
|
|
246
|
+
const size = total > 0 ? total : received;
|
|
247
|
+
display.done(`CMX ${tag} installed for ${os}/${arch} (${formatBytes(size)}).`);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
display.fail(`CMX installation failed: ${error.message}`);
|
|
250
|
+
throw error;
|
|
251
|
+
} finally {
|
|
252
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
253
|
+
display.close();
|
|
254
|
+
}
|
|
67
255
|
}
|
|
68
256
|
|
|
69
|
-
module.exports = { install };
|
|
257
|
+
module.exports = { install, download, renderBar, formatBytes, createProgress, checksumFor, writeExecutable };
|
|
70
258
|
|
|
71
259
|
if (require.main === module) {
|
|
72
|
-
install().catch((
|
|
73
|
-
console.error(`CMX installation failed: ${error.message}`);
|
|
260
|
+
install().catch(() => {
|
|
74
261
|
process.exitCode = 1;
|
|
75
262
|
});
|
|
76
263
|
}
|