@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.
- package/bin/install-lib.js +69 -15
- package/bin/install.js +127 -11
- package/bin/selftest.js +42 -3
- package/package.json +2 -2
package/bin/install-lib.js
CHANGED
|
@@ -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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
|
210
|
+
await downloadAndVerify(primary, asset, outPath);
|
|
99
211
|
return;
|
|
100
212
|
} catch (primaryErr) {
|
|
101
|
-
const
|
|
213
|
+
const fallback = await resolveReleaseAssetBundle({
|
|
102
214
|
version,
|
|
103
215
|
asset,
|
|
104
|
-
|
|
216
|
+
checksumsAsset,
|
|
217
|
+
getRelease: (kind) => withRetry(`release:${kind}`, () => getRelease(kind))
|
|
105
218
|
});
|
|
106
219
|
|
|
107
|
-
if (!
|
|
220
|
+
if (!fallback || !shouldUseFallbackUrl(primary.binaryUrl, fallback.binaryUrl)) {
|
|
108
221
|
throw primaryErr;
|
|
109
222
|
}
|
|
110
223
|
|
|
111
|
-
console.error(`mdv: fallback download ${
|
|
112
|
-
await
|
|
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: '
|
|
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(
|
|
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.
|
|
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
|
|
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",
|