@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 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)) await require('../scripts/install').install();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compressius/cmx",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
4
4
  "description": "CMX local context-compression gateway",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- function download(url, redirects = 0) {
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 = https.get(url, { headers: { 'User-Agent': 'cmx-npm-installer' } }, (response) => {
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) => chunks.push(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
- async function install() {
54
- const [binary, sums] = await Promise.all([
55
- download(`${releaseBase}/${asset}`),
56
- download(`${releaseBase}/SHA256SUMS`),
57
- ]);
58
- const actual = crypto.createHash('sha256').update(binary).digest('hex');
59
- if (actual !== checksumFor(sums)) throw new Error(`Checksum mismatch for ${asset}; CMX was not installed.`);
60
-
61
- const platformDir = path.join(__dirname, '..', 'platform');
62
- fs.mkdirSync(platformDir, { recursive: true });
63
- const destination = path.join(platformDir, asset);
64
- fs.writeFileSync(destination, binary, { mode: 0o755 });
65
- if (os !== 'windows') fs.chmodSync(destination, 0o755);
66
- console.log(`Installed CMX ${tag} for ${os}/${arch}.`);
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((error) => {
73
- console.error(`CMX installation failed: ${error.message}`);
260
+ install().catch(() => {
74
261
  process.exitCode = 1;
75
262
  });
76
263
  }