@dhruv2mars/mdv 0.0.13 → 0.0.15

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,13 @@ 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
+ Uses detected install manager (bun/pnpm/yarn/npm), prefers original installer metadata.
27
+
20
28
  Stream stdin:
21
29
 
22
30
  ```bash
@@ -37,3 +45,9 @@ tail -f notes.md | mdv --stream
37
45
  - `--no-watch` disable file watch
38
46
  - `--stream` read markdown from stdin
39
47
  - `--perf` show perf stats
48
+
49
+ ## Installer Env
50
+
51
+ - `MDV_INSTALL_DEBUG=1` local installer debug logs
52
+ - `MDV_INSTALL_TIMEOUT_MS` request timeout (default `15000`)
53
+ - `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,65 @@ export function checksumsAssetNameFromBinaryAsset(asset) {
14
16
  return checksumsAssetNameFor(m[1], m[2]);
15
17
  }
16
18
 
19
+ export function packageManagerHintFromEnv(env = process.env) {
20
+ const execPath = String(env.npm_execpath || '').toLowerCase();
21
+ if (execPath.includes('bun')) return 'bun';
22
+ if (execPath.includes('pnpm')) return 'pnpm';
23
+ if (execPath.includes('yarn')) return 'yarn';
24
+ if (execPath.includes('npm')) return 'npm';
25
+
26
+ const ua = String(env.npm_config_user_agent || '').toLowerCase();
27
+ if (ua.startsWith('bun/')) return 'bun';
28
+ if (ua.startsWith('pnpm/')) return 'pnpm';
29
+ if (ua.startsWith('yarn/')) return 'yarn';
30
+ if (ua.startsWith('npm/')) return 'npm';
31
+
32
+ return null;
33
+ }
34
+
35
+ function parseIntEnv(value, fallback, min, max) {
36
+ const parsed = Number.parseInt(String(value ?? ''), 10);
37
+ if (!Number.isFinite(parsed)) return fallback;
38
+ if (parsed < min) return min;
39
+ if (parsed > max) return max;
40
+ return parsed;
41
+ }
42
+
43
+ export function installTuningFromEnv(env = process.env) {
44
+ return {
45
+ retryAttempts: parseIntEnv(env.MDV_INSTALL_RETRY_ATTEMPTS, 3, 1, 10),
46
+ timeoutMs: parseIntEnv(env.MDV_INSTALL_TIMEOUT_MS, 15000, 1000, 120000),
47
+ backoffMs: parseIntEnv(env.MDV_INSTALL_BACKOFF_MS, 250, 50, 5000),
48
+ backoffJitterMs: parseIntEnv(env.MDV_INSTALL_BACKOFF_JITTER_MS, 100, 0, 2000)
49
+ };
50
+ }
51
+
52
+ export function computeBackoffDelay(attempt, backoffMs, backoffJitterMs, rand = Math.random) {
53
+ const scaled = Math.max(1, attempt) * Math.max(0, backoffMs);
54
+ if (backoffJitterMs <= 0) return scaled;
55
+ const jitter = Math.floor(Math.max(0, rand()) * (backoffJitterMs + 1));
56
+ return scaled + jitter;
57
+ }
58
+
59
+ export function cachePathsFor(installRoot, version, asset, checksumsAsset) {
60
+ const root = join(installRoot, 'cache', `v${version}`);
61
+ return {
62
+ cacheDir: root,
63
+ cacheBinary: join(root, asset),
64
+ cacheChecksums: join(root, checksumsAsset)
65
+ };
66
+ }
67
+
68
+ export function buildChecksumMismatchHelp({ asset, expected, actual, cachePath }) {
69
+ const shortExpected = String(expected || '').slice(0, 12);
70
+ const shortActual = String(actual || '').slice(0, 12);
71
+ return [
72
+ `checksum mismatch for ${asset}`,
73
+ `expected=${shortExpected}... actual=${shortActual}...`,
74
+ `clear cache and retry: rm -rf ${cachePath}`
75
+ ].join('; ');
76
+ }
77
+
17
78
  export function findAssetUrl(release, asset) {
18
79
  if (!release || !Array.isArray(release.assets)) return null;
19
80
  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,12 @@ 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,
27
+ packageManagerHintFromEnv,
22
28
  parseChecksumForAsset,
23
29
  resolveReleaseAssetBundle,
24
30
  shouldUseFallbackUrl
@@ -28,11 +34,16 @@ const REPO = 'Dhruv2mars/mdv';
28
34
 
29
35
  const installRoot = process.env.MDV_INSTALL_ROOT || join(homedir(), '.mdv');
30
36
  const binDir = join(installRoot, 'bin');
37
+ const metaPath = join(installRoot, 'install-meta.json');
31
38
  const binName = process.platform === 'win32' ? 'mdv.exe' : 'mdv';
32
39
  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);
40
+ const tuning = installTuningFromEnv(process.env);
41
+ const retryAttempts = tuning.retryAttempts;
42
+ const timeoutMs = tuning.timeoutMs;
43
+ const backoffMs = tuning.backoffMs;
44
+ const backoffJitterMs = tuning.backoffJitterMs;
45
+ const debugEnabled = process.env.MDV_INSTALL_DEBUG === '1';
46
+ const installStartedAt = Date.now();
36
47
 
37
48
  if (process.env.MDV_SKIP_DOWNLOAD === '1') process.exit(0);
38
49
  if (existsSync(dest)) process.exit(0);
@@ -42,26 +53,48 @@ mkdirSync(binDir, { recursive: true });
42
53
  const version = pkgVersion();
43
54
  const asset = assetNameFor();
44
55
  const checksumsAsset = checksumsAssetNameFor();
56
+ const cachePaths = cachePathsFor(installRoot, version, asset, checksumsAsset);
45
57
  const url = `https://github.com/${REPO}/releases/download/v${version}/${asset}`;
46
58
  const checksumsUrl = `https://github.com/${REPO}/releases/download/v${version}/${checksumsAsset}`;
47
59
  const tmp = `${dest}.tmp-${Date.now()}`;
60
+ mkdirSync(cachePaths.cacheDir, { recursive: true });
48
61
 
49
62
  try {
63
+ trace(`start retry=${retryAttempts} timeout=${timeoutMs}ms backoff=${backoffMs}ms jitter=${backoffJitterMs}ms`);
50
64
  console.error(`mdv: download ${asset} v${version}`);
51
- await downloadWithFallback(
52
- { binaryUrl: url, checksumsUrl },
53
- version,
54
- asset,
55
- checksumsAsset,
56
- tmp
57
- );
65
+ let checksumsText = null;
66
+ const restoredFromCache = await installFromCache(cachePaths, asset, tmp);
67
+ if (restoredFromCache) {
68
+ trace('cache-hit');
69
+ } else {
70
+ trace('cache-miss');
71
+ const result = await downloadWithFallback(
72
+ { binaryUrl: url, checksumsUrl },
73
+ version,
74
+ asset,
75
+ checksumsAsset,
76
+ tmp
77
+ );
78
+ if (result.source === 'primary') {
79
+ checksumsText = result.checksumsText;
80
+ }
81
+ }
58
82
  if (process.platform !== 'win32') chmodSync(tmp, 0o755);
59
83
  renameSync(tmp, dest);
84
+ if (checksumsText) {
85
+ persistCache(cachePaths, checksumsText, dest);
86
+ trace('cache-store');
87
+ }
88
+ persistInstallMeta();
89
+ trace('success');
60
90
  process.exit(0);
61
91
  } catch (err) {
62
92
  try { rmSync(tmp, { force: true }); } catch {}
63
-
64
- console.error(`mdv: download failed (${String(err)})`);
93
+ if (err && err.code === 'MDV_CHECKSUM_MISMATCH') {
94
+ console.error(`mdv: ${err.message}`);
95
+ } else {
96
+ console.error(`mdv: download failed (${String(err)})`);
97
+ }
65
98
 
66
99
  if (process.env.MDV_ALLOW_CARGO_FALLBACK === '1') {
67
100
  if (cargoInstallFallback()) {
@@ -170,7 +203,7 @@ async function withRetry(label, fn) {
170
203
  } catch (err) {
171
204
  lastErr = err;
172
205
  if (attempt >= retryAttempts) break;
173
- await sleep(backoffMs * attempt);
206
+ await sleep(computeBackoffDelay(attempt, backoffMs, backoffJitterMs));
174
207
  console.error(`mdv: retry ${label} (${attempt + 1}/${retryAttempts})`);
175
208
  }
176
209
  }
@@ -201,14 +234,15 @@ async function downloadAndVerify({ binaryUrl, checksumsUrl }, asset, outPath) {
201
234
 
202
235
  const actual = await sha256File(outPath);
203
236
  if (actual !== expected) {
204
- throw new Error(`checksum mismatch for ${asset}`);
237
+ throw checksumMismatchError(asset, expected, actual);
205
238
  }
239
+ return { checksumsText };
206
240
  }
207
241
 
208
242
  async function downloadWithFallback(primary, version, asset, checksumsAsset, outPath) {
209
243
  try {
210
- await downloadAndVerify(primary, asset, outPath);
211
- return;
244
+ const primaryVerified = await downloadAndVerify(primary, asset, outPath);
245
+ return { ...primaryVerified, source: 'primary' };
212
246
  } catch (primaryErr) {
213
247
  const fallback = await resolveReleaseAssetBundle({
214
248
  version,
@@ -222,7 +256,8 @@ async function downloadWithFallback(primary, version, asset, checksumsAsset, out
222
256
  }
223
257
 
224
258
  console.error(`mdv: fallback download ${fallback.binaryUrl}`);
225
- await downloadAndVerify(fallback, asset, outPath);
259
+ const fallbackVerified = await downloadAndVerify(fallback, asset, outPath);
260
+ return { ...fallbackVerified, source: 'fallback' };
226
261
  }
227
262
  }
228
263
 
@@ -270,6 +305,75 @@ function requestJson(url) {
270
305
  });
271
306
  }
272
307
 
308
+ function trace(msg) {
309
+ if (!debugEnabled) return;
310
+ const elapsed = Date.now() - installStartedAt;
311
+ console.error(`mdv:debug +${elapsed}ms ${msg}`);
312
+ }
313
+
314
+ async function installFromCache(paths, asset, outPath) {
315
+ if (!existsSync(paths.cacheBinary) || !existsSync(paths.cacheChecksums)) {
316
+ return false;
317
+ }
318
+
319
+ try {
320
+ const checksumsText = readFileSync(paths.cacheChecksums, 'utf8');
321
+ const expected = parseChecksumForAsset(checksumsText, asset);
322
+ if (!expected) {
323
+ trace('cache-invalid-missing-checksum-entry');
324
+ return false;
325
+ }
326
+ const actual = await sha256File(paths.cacheBinary);
327
+ if (actual !== expected) {
328
+ trace('cache-invalid-checksum-mismatch');
329
+ try { rmSync(paths.cacheBinary, { force: true }); } catch {}
330
+ try { rmSync(paths.cacheChecksums, { force: true }); } catch {}
331
+ return false;
332
+ }
333
+ copyFileSync(paths.cacheBinary, outPath);
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ function persistCache(paths, checksumsText, sourceBinaryPath) {
341
+ try {
342
+ copyFileSync(sourceBinaryPath, paths.cacheBinary);
343
+ writeFileSync(paths.cacheChecksums, checksumsText, 'utf8');
344
+ } catch {
345
+ trace('cache-store-failed');
346
+ }
347
+ }
348
+
349
+ function persistInstallMeta() {
350
+ const packageManager = packageManagerHintFromEnv(process.env);
351
+ const meta = {
352
+ packageManager,
353
+ version,
354
+ savedAt: new Date().toISOString()
355
+ };
356
+ try {
357
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8');
358
+ trace(`meta-store pm=${packageManager || 'unknown'}`);
359
+ } catch {
360
+ trace('meta-store-failed');
361
+ }
362
+ }
363
+
364
+ function checksumMismatchError(asset, expected, actual) {
365
+ const err = new Error(
366
+ buildChecksumMismatchHelp({
367
+ asset,
368
+ expected,
369
+ actual,
370
+ cachePath: cachePaths.cacheDir
371
+ })
372
+ );
373
+ err.code = 'MDV_CHECKSUM_MISMATCH';
374
+ return err;
375
+ }
376
+
273
377
  function cargoInstallFallback() {
274
378
  const probe = spawnSync('cargo', ['--version'], { stdio: 'ignore' });
275
379
  if (probe.status !== 0) return false;
package/bin/mdv-lib.js ADDED
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { packageManagerHintFromEnv } from './install-lib.js';
6
+
7
+ const PACKAGE_NAME = '@dhruv2mars/mdv@latest';
8
+ const SUPPORTED_PMS = new Set(['bun', 'pnpm', 'yarn', 'npm']);
9
+
10
+ export function binNameForPlatform(platform = process.platform) {
11
+ return platform === 'win32' ? 'mdv.exe' : 'mdv';
12
+ }
13
+
14
+ export function resolveInstallRoot(env = process.env, home = homedir()) {
15
+ return env.MDV_INSTALL_ROOT || join(home, '.mdv');
16
+ }
17
+
18
+ export function resolveInstalledBin(env = process.env, platform = process.platform, home = homedir()) {
19
+ const installRoot = resolveInstallRoot(env, home);
20
+ const binName = binNameForPlatform(platform);
21
+ return join(installRoot, 'bin', binName);
22
+ }
23
+
24
+ export function shouldRunUpdateCommand(args) {
25
+ return Array.isArray(args) && args.length > 0 && args[0] === 'update';
26
+ }
27
+
28
+ function updateArgsFor(pm) {
29
+ if (pm === 'bun') return ['add', '-g', PACKAGE_NAME];
30
+ if (pm === 'pnpm') return ['add', '-g', PACKAGE_NAME];
31
+ if (pm === 'yarn') return ['global', 'add', PACKAGE_NAME];
32
+ return ['install', '-g', PACKAGE_NAME];
33
+ }
34
+
35
+ function readInstallMeta(installRoot) {
36
+ const path = join(installRoot, 'install-meta.json');
37
+ if (!existsSync(path)) return null;
38
+ try {
39
+ return JSON.parse(readFileSync(path, 'utf8'));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function isSupportedPm(pm) {
46
+ return typeof pm === 'string' && SUPPORTED_PMS.has(pm);
47
+ }
48
+
49
+ function defaultProbe(command) {
50
+ const args = command === 'bun'
51
+ ? ['pm', 'ls', '-g']
52
+ : command === 'pnpm'
53
+ ? ['list', '-g', '--depth=0']
54
+ : command === 'yarn'
55
+ ? ['global', 'list', '--depth=0']
56
+ : ['list', '-g', '--depth=0'];
57
+
58
+ try {
59
+ const res = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe' });
60
+ return {
61
+ status: res.status ?? 1,
62
+ stdout: String(res.stdout || '')
63
+ };
64
+ } catch {
65
+ return { status: 1, stdout: '' };
66
+ }
67
+ }
68
+
69
+ function pmSearchOrder(preferred) {
70
+ const base = ['bun', 'pnpm', 'yarn', 'npm'];
71
+ if (!isSupportedPm(preferred)) return base;
72
+ return [preferred, ...base.filter((x) => x !== preferred)];
73
+ }
74
+
75
+ export function detectInstalledPackageManager(probe = defaultProbe, preferred = null) {
76
+ for (const command of pmSearchOrder(preferred)) {
77
+ const out = probe(command);
78
+ if ((out?.status ?? 1) !== 0) continue;
79
+ if (String(out?.stdout || '').includes('@dhruv2mars/mdv')) return command;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ export function resolveUpdateCommand(env = process.env) {
85
+ const installRoot = resolveInstallRoot(env);
86
+ const metaPm = readInstallMeta(installRoot)?.packageManager;
87
+ const envPm = packageManagerHintFromEnv(env);
88
+ const hintPm = isSupportedPm(metaPm) ? metaPm : (isSupportedPm(envPm) ? envPm : null);
89
+ const detectedPm = env === process.env && !hintPm
90
+ ? detectInstalledPackageManager(defaultProbe, null)
91
+ : null;
92
+ const manager = hintPm || detectedPm || 'npm';
93
+
94
+ if (manager === 'npm') {
95
+ const npmExecPath = env.npm_execpath;
96
+ if (typeof npmExecPath === 'string' && npmExecPath.endsWith('.js')) {
97
+ return {
98
+ command: process.execPath,
99
+ args: [npmExecPath, ...updateArgsFor('npm')]
100
+ };
101
+ }
102
+ }
103
+
104
+ if (isSupportedPm(manager)) {
105
+ return {
106
+ command: manager,
107
+ args: updateArgsFor(manager)
108
+ };
109
+ }
110
+
111
+ return {
112
+ command: 'npm',
113
+ args: updateArgsFor('npm')
114
+ };
115
+ }
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.15",
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
  },