@dhruv2mars/mdv 0.0.8 → 0.0.10

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.
@@ -3,26 +3,80 @@ export function assetNameFor(platform = process.platform, arch = process.arch) {
3
3
  return `mdv-${platform}-${arch}${ext}`;
4
4
  }
5
5
 
6
+ export function checksumsAssetNameFor(platform = process.platform, arch = process.arch) {
7
+ return `checksums-${platform}-${arch}.txt`;
8
+ }
9
+
10
+ export function checksumsAssetNameFromBinaryAsset(asset) {
11
+ if (typeof asset !== 'string') return null;
12
+ const m = asset.match(/^mdv-([a-z0-9]+)-([a-z0-9_]+)(?:\.exe)?$/i);
13
+ if (!m) return null;
14
+ return checksumsAssetNameFor(m[1], m[2]);
15
+ }
16
+
6
17
  export function findAssetUrl(release, asset) {
7
18
  if (!release || !Array.isArray(release.assets)) return null;
8
- const match = release.assets.find((item) => item?.name === asset);
9
- if (!match) return null;
10
- return match.browser_download_url || null;
19
+ for (const item of release.assets) {
20
+ if (item?.name !== asset) continue;
21
+ if (typeof item.browser_download_url === 'string' && item.browser_download_url.length > 0) {
22
+ return item.browser_download_url;
23
+ }
24
+ }
25
+ return null;
11
26
  }
12
27
 
13
- export async function resolveReleaseAssetUrl({ version, asset, getRelease }) {
14
- const tag = `tags/v${version}`;
15
- try {
16
- const tagged = await getRelease(tag);
17
- const taggedUrl = findAssetUrl(tagged, asset);
18
- if (taggedUrl) return taggedUrl;
19
- } catch {}
28
+ export function shouldUseFallbackUrl(primaryUrl, fallbackUrl) {
29
+ if (typeof fallbackUrl !== 'string' || fallbackUrl.length === 0) return false;
30
+ if (typeof primaryUrl !== 'string' || primaryUrl.length === 0) return true;
31
+ return fallbackUrl !== primaryUrl;
32
+ }
20
33
 
21
- try {
22
- const latest = await getRelease('latest');
23
- const latestUrl = findAssetUrl(latest, asset);
24
- if (latestUrl) return latestUrl;
25
- } catch {}
34
+ export function parseChecksumForAsset(text, asset) {
35
+ if (typeof text !== 'string' || typeof asset !== 'string' || asset.length === 0) return null;
36
+ for (const line of text.split(/\r?\n/)) {
37
+ const m = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
38
+ if (!m) continue;
39
+ if (m[2].trim() !== asset) continue;
40
+ return m[1].toLowerCase();
41
+ }
42
+ return null;
43
+ }
44
+
45
+ export async function resolveReleaseAssetBundle({ version, asset, checksumsAsset, getRelease }) {
46
+ const tagged = `tags/v${version}`;
47
+ const taggedBundle = await resolveReleaseAssetBundleFromKind({
48
+ kind: tagged,
49
+ asset,
50
+ checksumsAsset,
51
+ getRelease
52
+ });
53
+ if (taggedBundle) return taggedBundle;
54
+
55
+ const latestBundle = await resolveReleaseAssetBundleFromKind({
56
+ kind: 'latest',
57
+ asset,
58
+ checksumsAsset,
59
+ getRelease
60
+ });
61
+ if (latestBundle) return latestBundle;
26
62
 
27
63
  return null;
28
64
  }
65
+
66
+ async function resolveReleaseAssetBundleFromKind({ kind, asset, checksumsAsset, getRelease }) {
67
+ try {
68
+ const release = await getRelease(kind);
69
+ const binaryUrl = findAssetUrl(release, asset);
70
+ const checksumsUrl = findAssetUrl(release, checksumsAsset);
71
+ if (!binaryUrl || !checksumsUrl) return null;
72
+ return { binaryUrl, checksumsUrl };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export async function resolveReleaseAssetUrl({ version, asset, getRelease }) {
79
+ const checksumsAsset = checksumsAssetNameFromBinaryAsset(asset) || checksumsAssetNameFor();
80
+ const bundle = await resolveReleaseAssetBundle({ version, asset, checksumsAsset, getRelease });
81
+ return bundle?.binaryUrl || null;
82
+ }
package/bin/install.js CHANGED
@@ -4,17 +4,25 @@ import {
4
4
  copyFileSync,
5
5
  createWriteStream,
6
6
  existsSync,
7
+ createReadStream,
7
8
  mkdirSync,
8
9
  readFileSync,
9
10
  rmSync,
10
11
  renameSync
11
12
  } from 'node:fs';
13
+ import { createHash } from 'node:crypto';
12
14
  import { homedir } from 'node:os';
13
15
  import { join } from 'node:path';
14
16
  import https from 'node:https';
15
17
  import { spawnSync } from 'node:child_process';
16
18
  import { fileURLToPath } from 'node:url';
17
- import { assetNameFor, resolveReleaseAssetUrl } from './install-lib.js';
19
+ import {
20
+ assetNameFor,
21
+ checksumsAssetNameFor,
22
+ parseChecksumForAsset,
23
+ resolveReleaseAssetBundle,
24
+ shouldUseFallbackUrl
25
+ } from './install-lib.js';
18
26
 
19
27
  const REPO = 'Dhruv2mars/mdv';
20
28
 
@@ -22,6 +30,9 @@ const installRoot = process.env.MDV_INSTALL_ROOT || join(homedir(), '.mdv');
22
30
  const binDir = join(installRoot, 'bin');
23
31
  const binName = process.platform === 'win32' ? 'mdv.exe' : 'mdv';
24
32
  const dest = join(binDir, binName);
33
+ const retryAttempts = Number(process.env.MDV_INSTALL_RETRY_ATTEMPTS || 3);
34
+ const timeoutMs = Number(process.env.MDV_INSTALL_TIMEOUT_MS || 15000);
35
+ const backoffMs = Number(process.env.MDV_INSTALL_BACKOFF_MS || 250);
25
36
 
26
37
  if (process.env.MDV_SKIP_DOWNLOAD === '1') process.exit(0);
27
38
  if (existsSync(dest)) process.exit(0);
@@ -30,12 +41,20 @@ mkdirSync(binDir, { recursive: true });
30
41
 
31
42
  const version = pkgVersion();
32
43
  const asset = assetNameFor();
44
+ const checksumsAsset = checksumsAssetNameFor();
33
45
  const url = `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
46
+ const checksumsUrl = `https://github.com/${REPO}/releases/download/v${version}/${checksumsAsset}`;
34
47
  const tmp = `${dest}.tmp-${Date.now()}`;
35
48
 
36
49
  try {
37
50
  console.error(`mdv: download ${asset} v${version}`);
38
- await downloadWithFallback(url, version, asset, tmp);
51
+ await downloadWithFallback(
52
+ { binaryUrl: url, checksumsUrl },
53
+ version,
54
+ asset,
55
+ checksumsAsset,
56
+ tmp
57
+ );
39
58
  if (process.platform !== 'win32') chmodSync(tmp, 0o755);
40
59
  renameSync(tmp, dest);
41
60
  process.exit(0);
@@ -65,14 +84,23 @@ function pkgVersion() {
65
84
  }
66
85
 
67
86
  function download(url, outPath) {
87
+ return downloadWithRedirects(url, outPath, 0);
88
+ }
89
+
90
+ function downloadWithRedirects(url, outPath, redirects) {
68
91
  return new Promise((resolve, reject) => {
92
+ if (redirects > 5) {
93
+ reject(new Error('too many redirects'));
94
+ return;
95
+ }
96
+
69
97
  const req = https.get(
70
98
  url,
71
99
  { headers: { 'User-Agent': 'mdv-installer', Accept: 'application/octet-stream' } },
72
100
  (res) => {
73
101
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
74
102
  res.resume();
75
- download(res.headers.location, outPath).then(resolve, reject);
103
+ downloadWithRedirects(res.headers.location, outPath, redirects + 1).then(resolve, reject);
76
104
  return;
77
105
  }
78
106
 
@@ -85,31 +113,116 @@ function download(url, outPath) {
85
113
  const file = createWriteStream(outPath);
86
114
  res.pipe(file);
87
115
  file.on('finish', () => file.close(resolve));
88
- file.on('error', reject);
116
+ file.on('error', (err) => {
117
+ try {
118
+ rmSync(outPath, { force: true });
119
+ } catch {}
120
+ reject(err);
121
+ });
122
+ }
123
+ );
124
+
125
+ req.setTimeout(timeoutMs, () => {
126
+ req.destroy(new Error(`timeout ${timeoutMs}ms`));
127
+ });
128
+ req.on('error', reject);
129
+ });
130
+ }
131
+
132
+ function requestText(url) {
133
+ return new Promise((resolve, reject) => {
134
+ const req = https.get(
135
+ url,
136
+ { headers: { 'User-Agent': 'mdv-installer', Accept: 'text/plain' } },
137
+ (res) => {
138
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
139
+ res.resume();
140
+ requestText(res.headers.location).then(resolve, reject);
141
+ return;
142
+ }
143
+ if (res.statusCode !== 200) {
144
+ res.resume();
145
+ reject(new Error(`http ${res.statusCode}`));
146
+ return;
147
+ }
148
+
149
+ let data = '';
150
+ res.setEncoding('utf8');
151
+ res.on('data', (chunk) => {
152
+ data += chunk;
153
+ });
154
+ res.on('end', () => resolve(data));
89
155
  }
90
156
  );
91
157
 
158
+ req.setTimeout(timeoutMs, () => {
159
+ req.destroy(new Error(`timeout ${timeoutMs}ms`));
160
+ });
92
161
  req.on('error', reject);
93
162
  });
94
163
  }
95
164
 
96
- async function downloadWithFallback(primaryUrl, version, asset, outPath) {
165
+ async function withRetry(label, fn) {
166
+ let lastErr = null;
167
+ for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
168
+ try {
169
+ return await fn();
170
+ } catch (err) {
171
+ lastErr = err;
172
+ if (attempt >= retryAttempts) break;
173
+ await sleep(backoffMs * attempt);
174
+ console.error(`mdv: retry ${label} (${attempt + 1}/${retryAttempts})`);
175
+ }
176
+ }
177
+ throw lastErr || new Error(`retry failed: ${label}`);
178
+ }
179
+
180
+ function sleep(ms) {
181
+ return new Promise((resolve) => setTimeout(resolve, ms));
182
+ }
183
+
184
+ async function sha256File(path) {
185
+ return new Promise((resolve, reject) => {
186
+ const hash = createHash('sha256');
187
+ const inStream = createReadStream(path);
188
+ inStream.on('error', reject);
189
+ inStream.on('data', (chunk) => hash.update(chunk));
190
+ inStream.on('end', () => resolve(hash.digest('hex')));
191
+ });
192
+ }
193
+
194
+ async function downloadAndVerify({ binaryUrl, checksumsUrl }, asset, outPath) {
195
+ await withRetry('binary', () => download(binaryUrl, outPath));
196
+ const checksumsText = await withRetry('checksums', () => requestText(checksumsUrl));
197
+ const expected = parseChecksumForAsset(checksumsText, asset);
198
+ if (!expected) {
199
+ throw new Error(`checksum missing for ${asset}`);
200
+ }
201
+
202
+ const actual = await sha256File(outPath);
203
+ if (actual !== expected) {
204
+ throw new Error(`checksum mismatch for ${asset}`);
205
+ }
206
+ }
207
+
208
+ async function downloadWithFallback(primary, version, asset, checksumsAsset, outPath) {
97
209
  try {
98
- await download(primaryUrl, outPath);
210
+ await downloadAndVerify(primary, asset, outPath);
99
211
  return;
100
212
  } catch (primaryErr) {
101
- const fallbackUrl = await resolveReleaseAssetUrl({
213
+ const fallback = await resolveReleaseAssetBundle({
102
214
  version,
103
215
  asset,
104
- getRelease: getRelease
216
+ checksumsAsset,
217
+ getRelease: (kind) => withRetry(`release:${kind}`, () => getRelease(kind))
105
218
  });
106
219
 
107
- if (!fallbackUrl || fallbackUrl === primaryUrl) {
220
+ if (!fallback || !shouldUseFallbackUrl(primary.binaryUrl, fallback.binaryUrl)) {
108
221
  throw primaryErr;
109
222
  }
110
223
 
111
- console.error(`mdv: fallback download ${fallbackUrl}`);
112
- await download(fallbackUrl, outPath);
224
+ console.error(`mdv: fallback download ${fallback.binaryUrl}`);
225
+ await downloadAndVerify(fallback, asset, outPath);
113
226
  }
114
227
  }
115
228
 
@@ -150,6 +263,9 @@ function requestJson(url) {
150
263
  });
151
264
  });
152
265
 
266
+ req.setTimeout(timeoutMs, () => {
267
+ req.destroy(new Error(`timeout ${timeoutMs}ms`));
268
+ });
153
269
  req.on('error', reject);
154
270
  });
155
271
  }
package/bin/selftest.js CHANGED
@@ -1,16 +1,55 @@
1
1
  #!/usr/bin/env node
2
- import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
 
7
7
  const root = mkdtempSync(join(tmpdir(), 'mdv-install-'));
8
8
  const env = { ...process.env, MDV_INSTALL_ROOT: root };
9
+ const binName = process.platform === 'win32' ? 'mdv.exe' : 'mdv';
10
+ const installedBin = join(root, 'bin', binName);
9
11
 
10
12
  const res = spawnSync(process.execPath, [new URL('./install.js', import.meta.url).pathname], {
11
- stdio: 'inherit',
13
+ stdio: 'pipe',
14
+ encoding: 'utf8',
12
15
  env
13
16
  });
14
17
 
18
+ if ((res.status ?? 1) !== 0) {
19
+ printDiag('installer exited non-zero', { root, status: res.status, stderr: res.stderr });
20
+ rmSync(root, { recursive: true, force: true });
21
+ process.exit(res.status ?? 1);
22
+ }
23
+
24
+ if (!existsSync(installedBin)) {
25
+ printDiag('installed binary missing after installer success', { root, expected: installedBin });
26
+ rmSync(root, { recursive: true, force: true });
27
+ process.exit(1);
28
+ }
29
+
30
+ const smoke = spawnSync(installedBin, ['--help'], {
31
+ stdio: 'pipe',
32
+ encoding: 'utf8',
33
+ env
34
+ });
35
+ if ((smoke.status ?? 1) !== 0) {
36
+ printDiag('installed binary failed --help', {
37
+ root,
38
+ expected: installedBin,
39
+ status: smoke.status,
40
+ stderr: smoke.stderr
41
+ });
42
+ rmSync(root, { recursive: true, force: true });
43
+ process.exit(smoke.status ?? 1);
44
+ }
45
+
15
46
  rmSync(root, { recursive: true, force: true });
16
- process.exit(res.status ?? 1);
47
+ process.exit(0);
48
+
49
+ function printDiag(message, data) {
50
+ console.error(`mdv selftest: ${message}`);
51
+ for (const [k, v] of Object.entries(data)) {
52
+ if (!v) continue;
53
+ console.error(`mdv selftest: ${k}=${String(v).trim()}`);
54
+ }
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dhruv2mars/mdv",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Terminal-first markdown visualizer/editor",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "selftest": "node bin/selftest.js",
16
16
  "lint": "node --check bin/mdv.js && node --check bin/install.js && node --check bin/install-lib.js && node --check bin/selftest.js",
17
17
  "test": "node scripts/test.js",
18
- "coverage": "c8 --all --include bin/install-lib.js --reporter text-summary --check-coverage --lines 100 --functions 100 --branches 80 --statements 100 node scripts/test.js"
18
+ "coverage": "c8 --all --include bin/install-lib.js --reporter text-summary --check-coverage --lines 95 --functions 95 --branches 80 --statements 95 node scripts/test.js"
19
19
  },
20
20
  "keywords": [
21
21
  "markdown",