@dhruv2mars/mdv 0.0.13 → 0.0.14

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/README.md CHANGED
@@ -10,6 +10,7 @@ npm i -g @dhruv2mars/mdv
10
10
 
11
11
  First run downloads native `mdv` binary.
12
12
  Assets are resolved from GitHub Releases for your platform/arch.
13
+ Installer keeps verified cache under `~/.mdv/cache`.
13
14
 
14
15
  ## Usage
15
16
 
@@ -17,6 +18,12 @@ Assets are resolved from GitHub Releases for your platform/arch.
17
18
  mdv README.md
18
19
  ```
19
20
 
21
+ Manual upgrade:
22
+
23
+ ```bash
24
+ mdv update
25
+ ```
26
+
20
27
  Stream stdin:
21
28
 
22
29
  ```bash
@@ -37,3 +44,9 @@ tail -f notes.md | mdv --stream
37
44
  - `--no-watch` disable file watch
38
45
  - `--stream` read markdown from stdin
39
46
  - `--perf` show perf stats
47
+
48
+ ## Installer Env
49
+
50
+ - `MDV_INSTALL_DEBUG=1` local installer debug logs
51
+ - `MDV_INSTALL_TIMEOUT_MS` request timeout (default `15000`)
52
+ - `MDV_INSTALL_RETRY_ATTEMPTS` retries (default `3`)
@@ -1,3 +1,5 @@
1
+ import { join } from 'node:path';
2
+
1
3
  export function assetNameFor(platform = process.platform, arch = process.arch) {
2
4
  const ext = platform === 'win32' ? '.exe' : '';
3
5
  return `mdv-${platform}-${arch}${ext}`;
@@ -14,6 +16,49 @@ export function checksumsAssetNameFromBinaryAsset(asset) {
14
16
  return checksumsAssetNameFor(m[1], m[2]);
15
17
  }
16
18
 
19
+ function parseIntEnv(value, fallback, min, max) {
20
+ const parsed = Number.parseInt(String(value ?? ''), 10);
21
+ if (!Number.isFinite(parsed)) return fallback;
22
+ if (parsed < min) return min;
23
+ if (parsed > max) return max;
24
+ return parsed;
25
+ }
26
+
27
+ export function installTuningFromEnv(env = process.env) {
28
+ return {
29
+ retryAttempts: parseIntEnv(env.MDV_INSTALL_RETRY_ATTEMPTS, 3, 1, 10),
30
+ timeoutMs: parseIntEnv(env.MDV_INSTALL_TIMEOUT_MS, 15000, 1000, 120000),
31
+ backoffMs: parseIntEnv(env.MDV_INSTALL_BACKOFF_MS, 250, 50, 5000),
32
+ backoffJitterMs: parseIntEnv(env.MDV_INSTALL_BACKOFF_JITTER_MS, 100, 0, 2000)
33
+ };
34
+ }
35
+
36
+ export function computeBackoffDelay(attempt, backoffMs, backoffJitterMs, rand = Math.random) {
37
+ const scaled = Math.max(1, attempt) * Math.max(0, backoffMs);
38
+ if (backoffJitterMs <= 0) return scaled;
39
+ const jitter = Math.floor(Math.max(0, rand()) * (backoffJitterMs + 1));
40
+ return scaled + jitter;
41
+ }
42
+
43
+ export function cachePathsFor(installRoot, version, asset, checksumsAsset) {
44
+ const root = join(installRoot, 'cache', `v${version}`);
45
+ return {
46
+ cacheDir: root,
47
+ cacheBinary: join(root, asset),
48
+ cacheChecksums: join(root, checksumsAsset)
49
+ };
50
+ }
51
+
52
+ export function buildChecksumMismatchHelp({ asset, expected, actual, cachePath }) {
53
+ const shortExpected = String(expected || '').slice(0, 12);
54
+ const shortActual = String(actual || '').slice(0, 12);
55
+ return [
56
+ `checksum mismatch for ${asset}`,
57
+ `expected=${shortExpected}... actual=${shortActual}...`,
58
+ `clear cache and retry: rm -rf ${cachePath}`
59
+ ].join('; ');
60
+ }
61
+
17
62
  export function findAssetUrl(release, asset) {
18
63
  if (!release || !Array.isArray(release.assets)) return null;
19
64
  for (const item of release.assets) {
package/bin/install.js CHANGED
@@ -8,7 +8,8 @@ import {
8
8
  mkdirSync,
9
9
  readFileSync,
10
10
  rmSync,
11
- renameSync
11
+ renameSync,
12
+ writeFileSync
12
13
  } from 'node:fs';
13
14
  import { createHash } from 'node:crypto';
14
15
  import { homedir } from 'node:os';
@@ -18,7 +19,11 @@ import { spawnSync } from 'node:child_process';
18
19
  import { fileURLToPath } from 'node:url';
19
20
  import {
20
21
  assetNameFor,
22
+ buildChecksumMismatchHelp,
23
+ cachePathsFor,
21
24
  checksumsAssetNameFor,
25
+ computeBackoffDelay,
26
+ installTuningFromEnv,
22
27
  parseChecksumForAsset,
23
28
  resolveReleaseAssetBundle,
24
29
  shouldUseFallbackUrl
@@ -30,9 +35,13 @@ const installRoot = process.env.MDV_INSTALL_ROOT || join(homedir(), '.mdv');
30
35
  const binDir = join(installRoot, 'bin');
31
36
  const binName = process.platform === 'win32' ? 'mdv.exe' : 'mdv';
32
37
  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);
38
+ const tuning = installTuningFromEnv(process.env);
39
+ const retryAttempts = tuning.retryAttempts;
40
+ const timeoutMs = tuning.timeoutMs;
41
+ const backoffMs = tuning.backoffMs;
42
+ const backoffJitterMs = tuning.backoffJitterMs;
43
+ const debugEnabled = process.env.MDV_INSTALL_DEBUG === '1';
44
+ const installStartedAt = Date.now();
36
45
 
37
46
  if (process.env.MDV_SKIP_DOWNLOAD === '1') process.exit(0);
38
47
  if (existsSync(dest)) process.exit(0);
@@ -42,26 +51,47 @@ mkdirSync(binDir, { recursive: true });
42
51
  const version = pkgVersion();
43
52
  const asset = assetNameFor();
44
53
  const checksumsAsset = checksumsAssetNameFor();
54
+ const cachePaths = cachePathsFor(installRoot, version, asset, checksumsAsset);
45
55
  const url = `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
46
56
  const checksumsUrl = `https://github.com/${REPO}/releases/download/v${version}/${checksumsAsset}`;
47
57
  const tmp = `${dest}.tmp-${Date.now()}`;
58
+ mkdirSync(cachePaths.cacheDir, { recursive: true });
48
59
 
49
60
  try {
61
+ trace(`start retry=${retryAttempts} timeout=${timeoutMs}ms backoff=${backoffMs}ms jitter=${backoffJitterMs}ms`);
50
62
  console.error(`mdv: download ${asset} v${version}`);
51
- await downloadWithFallback(
52
- { binaryUrl: url, checksumsUrl },
53
- version,
54
- asset,
55
- checksumsAsset,
56
- tmp
57
- );
63
+ let checksumsText = null;
64
+ const restoredFromCache = await installFromCache(cachePaths, asset, tmp);
65
+ if (restoredFromCache) {
66
+ trace('cache-hit');
67
+ } else {
68
+ trace('cache-miss');
69
+ const result = await downloadWithFallback(
70
+ { binaryUrl: url, checksumsUrl },
71
+ version,
72
+ asset,
73
+ checksumsAsset,
74
+ tmp
75
+ );
76
+ if (result.source === 'primary') {
77
+ checksumsText = result.checksumsText;
78
+ }
79
+ }
58
80
  if (process.platform !== 'win32') chmodSync(tmp, 0o755);
59
81
  renameSync(tmp, dest);
82
+ if (checksumsText) {
83
+ persistCache(cachePaths, checksumsText, dest);
84
+ trace('cache-store');
85
+ }
86
+ trace('success');
60
87
  process.exit(0);
61
88
  } catch (err) {
62
89
  try { rmSync(tmp, { force: true }); } catch {}
63
-
64
- console.error(`mdv: download failed (${String(err)})`);
90
+ if (err && err.code === 'MDV_CHECKSUM_MISMATCH') {
91
+ console.error(`mdv: ${err.message}`);
92
+ } else {
93
+ console.error(`mdv: download failed (${String(err)})`);
94
+ }
65
95
 
66
96
  if (process.env.MDV_ALLOW_CARGO_FALLBACK === '1') {
67
97
  if (cargoInstallFallback()) {
@@ -170,7 +200,7 @@ async function withRetry(label, fn) {
170
200
  } catch (err) {
171
201
  lastErr = err;
172
202
  if (attempt >= retryAttempts) break;
173
- await sleep(backoffMs * attempt);
203
+ await sleep(computeBackoffDelay(attempt, backoffMs, backoffJitterMs));
174
204
  console.error(`mdv: retry ${label} (${attempt + 1}/${retryAttempts})`);
175
205
  }
176
206
  }
@@ -201,14 +231,15 @@ async function downloadAndVerify({ binaryUrl, checksumsUrl }, asset, outPath) {
201
231
 
202
232
  const actual = await sha256File(outPath);
203
233
  if (actual !== expected) {
204
- throw new Error(`checksum mismatch for ${asset}`);
234
+ throw checksumMismatchError(asset, expected, actual);
205
235
  }
236
+ return { checksumsText };
206
237
  }
207
238
 
208
239
  async function downloadWithFallback(primary, version, asset, checksumsAsset, outPath) {
209
240
  try {
210
- await downloadAndVerify(primary, asset, outPath);
211
- return;
241
+ const primaryVerified = await downloadAndVerify(primary, asset, outPath);
242
+ return { ...primaryVerified, source: 'primary' };
212
243
  } catch (primaryErr) {
213
244
  const fallback = await resolveReleaseAssetBundle({
214
245
  version,
@@ -222,7 +253,8 @@ async function downloadWithFallback(primary, version, asset, checksumsAsset, out
222
253
  }
223
254
 
224
255
  console.error(`mdv: fallback download ${fallback.binaryUrl}`);
225
- await downloadAndVerify(fallback, asset, outPath);
256
+ const fallbackVerified = await downloadAndVerify(fallback, asset, outPath);
257
+ return { ...fallbackVerified, source: 'fallback' };
226
258
  }
227
259
  }
228
260
 
@@ -270,6 +302,60 @@ function requestJson(url) {
270
302
  });
271
303
  }
272
304
 
305
+ function trace(msg) {
306
+ if (!debugEnabled) return;
307
+ const elapsed = Date.now() - installStartedAt;
308
+ console.error(`mdv:debug +${elapsed}ms ${msg}`);
309
+ }
310
+
311
+ async function installFromCache(paths, asset, outPath) {
312
+ if (!existsSync(paths.cacheBinary) || !existsSync(paths.cacheChecksums)) {
313
+ return false;
314
+ }
315
+
316
+ try {
317
+ const checksumsText = readFileSync(paths.cacheChecksums, 'utf8');
318
+ const expected = parseChecksumForAsset(checksumsText, asset);
319
+ if (!expected) {
320
+ trace('cache-invalid-missing-checksum-entry');
321
+ return false;
322
+ }
323
+ const actual = await sha256File(paths.cacheBinary);
324
+ if (actual !== expected) {
325
+ trace('cache-invalid-checksum-mismatch');
326
+ try { rmSync(paths.cacheBinary, { force: true }); } catch {}
327
+ try { rmSync(paths.cacheChecksums, { force: true }); } catch {}
328
+ return false;
329
+ }
330
+ copyFileSync(paths.cacheBinary, outPath);
331
+ return true;
332
+ } catch {
333
+ return false;
334
+ }
335
+ }
336
+
337
+ function persistCache(paths, checksumsText, sourceBinaryPath) {
338
+ try {
339
+ copyFileSync(sourceBinaryPath, paths.cacheBinary);
340
+ writeFileSync(paths.cacheChecksums, checksumsText, 'utf8');
341
+ } catch {
342
+ trace('cache-store-failed');
343
+ }
344
+ }
345
+
346
+ function checksumMismatchError(asset, expected, actual) {
347
+ const err = new Error(
348
+ buildChecksumMismatchHelp({
349
+ asset,
350
+ expected,
351
+ actual,
352
+ cachePath: cachePaths.cacheDir
353
+ })
354
+ );
355
+ err.code = 'MDV_CHECKSUM_MISMATCH';
356
+ return err;
357
+ }
358
+
273
359
  function cargoInstallFallback() {
274
360
  const probe = spawnSync('cargo', ['--version'], { stdio: 'ignore' });
275
361
  if (probe.status !== 0) return false;
package/bin/mdv-lib.js ADDED
@@ -0,0 +1,37 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ const PACKAGE_NAME = '@dhruv2mars/mdv@latest';
5
+
6
+ export function binNameForPlatform(platform = process.platform) {
7
+ return platform === 'win32' ? 'mdv.exe' : 'mdv';
8
+ }
9
+
10
+ export function resolveInstallRoot(env = process.env, home = homedir()) {
11
+ return env.MDV_INSTALL_ROOT || join(home, '.mdv');
12
+ }
13
+
14
+ export function resolveInstalledBin(env = process.env, platform = process.platform, home = homedir()) {
15
+ const installRoot = resolveInstallRoot(env, home);
16
+ const binName = binNameForPlatform(platform);
17
+ return join(installRoot, 'bin', binName);
18
+ }
19
+
20
+ export function shouldRunUpdateCommand(args) {
21
+ return Array.isArray(args) && args.length > 0 && args[0] === 'update';
22
+ }
23
+
24
+ export function resolveUpdateCommand(env = process.env) {
25
+ const npmExecPath = env.npm_execpath;
26
+ if (typeof npmExecPath === 'string' && npmExecPath.endsWith('.js')) {
27
+ return {
28
+ command: process.execPath,
29
+ args: [npmExecPath, 'install', '-g', PACKAGE_NAME]
30
+ };
31
+ }
32
+
33
+ return {
34
+ command: 'npm',
35
+ args: ['install', '-g', PACKAGE_NAME]
36
+ };
37
+ }
package/bin/mdv.js CHANGED
@@ -1,18 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
3
  import { spawnSync } from 'node:child_process';
6
4
  import { fileURLToPath } from 'node:url';
5
+ import {
6
+ resolveInstalledBin,
7
+ resolveUpdateCommand,
8
+ shouldRunUpdateCommand
9
+ } from './mdv-lib.js';
7
10
 
8
11
  const args = process.argv.slice(2);
9
12
 
13
+ if (shouldRunUpdateCommand(args)) {
14
+ const update = resolveUpdateCommand(process.env);
15
+ const res = spawnSync(update.command, update.args, { stdio: 'inherit', env: process.env });
16
+ process.exit(res.status ?? 1);
17
+ }
18
+
10
19
  const envBin = process.env.MDV_BIN;
11
20
  if (envBin) run(envBin, args);
12
21
 
13
- const installRoot = process.env.MDV_INSTALL_ROOT || join(homedir(), '.mdv');
14
- const binName = process.platform === 'win32' ? 'mdv.exe' : 'mdv';
15
- const installedBin = join(installRoot, 'bin', binName);
22
+ const installedBin = resolveInstalledBin(process.env, process.platform);
16
23
 
17
24
  if (!existsSync(installedBin)) {
18
25
  const here = fileURLToPath(new URL('.', import.meta.url));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dhruv2mars/mdv",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Terminal-first markdown visualizer/editor",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "mdv": "node bin/mdv.js",
14
14
  "postinstall": "node bin/install.js",
15
15
  "selftest": "node bin/selftest.js",
16
- "lint": "node --check bin/mdv.js && node --check bin/install.js && node --check bin/install-lib.js && node --check bin/selftest.js",
16
+ "lint": "node --check bin/mdv.js && node --check bin/mdv-lib.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
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
  },