pandite 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +76 -2
- package/bin/pandite.js +14 -0
- package/lib/archive.js +153 -0
- package/lib/cli.js +188 -0
- package/lib/errors.js +18 -0
- package/lib/github.js +109 -0
- package/lib/install.js +529 -0
- package/lib/net.js +159 -0
- package/lib/platform.js +125 -0
- package/lib/version.js +67 -0
- package/package.json +32 -4
package/lib/install.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// Install, update and remove Pandite. Every access to the system and the
|
|
2
|
+
// network goes through the context `ctx`, so test.mjs runs all of it
|
|
3
|
+
// offline:
|
|
4
|
+
// platform, arch, glibc the system, as Node.js names it
|
|
5
|
+
// env, home, tmpdir environment, home folder, temporary folder
|
|
6
|
+
// systemApplications the macOS folder of --system (/Applications)
|
|
7
|
+
// run(command, args, { env, capture })
|
|
8
|
+
// start a program; resolves { code, signal, stdout, stderr }
|
|
9
|
+
// sleep(ms) wait
|
|
10
|
+
// net { fetch, userAgent, now } for net.js
|
|
11
|
+
// out(line), info(line) result lines (stdout) and progress lines (stderr)
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
chmodSync,
|
|
15
|
+
cpSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { untar, unzip } from './archive.js';
|
|
26
|
+
import { InstallerError } from './errors.js';
|
|
27
|
+
import { getRelease, listReleases, parseSums } from './github.js';
|
|
28
|
+
import { download, getText } from './net.js';
|
|
29
|
+
import {
|
|
30
|
+
MAC_APP,
|
|
31
|
+
RELEASES_URL,
|
|
32
|
+
SUMS_FILE,
|
|
33
|
+
WINDOWS_APP_ID,
|
|
34
|
+
chooseAsset,
|
|
35
|
+
dataDir,
|
|
36
|
+
systemName,
|
|
37
|
+
tarballTop,
|
|
38
|
+
} from './platform.js';
|
|
39
|
+
import { compareVersions } from './version.js';
|
|
40
|
+
|
|
41
|
+
/** Arguments of the Windows setup program (Inno Setup): silent, current user. */
|
|
42
|
+
export const SETUP_ARGS = ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', '/CURRENTUSER'];
|
|
43
|
+
export const UNINSTALL_ARGS = ['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'];
|
|
44
|
+
/** The uninstall entry that the setup program writes, under HKCU or HKLM. */
|
|
45
|
+
export const UNINSTALL_KEY = `Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${WINDOWS_APP_ID}_is1`;
|
|
46
|
+
export const SH = '/bin/sh';
|
|
47
|
+
export const HDIUTIL = '/usr/bin/hdiutil';
|
|
48
|
+
export const MARKER = 'installed.json';
|
|
49
|
+
const REGISTRY_ROOTS = ['HKCU', 'HKLM'];
|
|
50
|
+
const UNINSTALL_WAIT_MS = 60_000;
|
|
51
|
+
const UNINSTALL_POLL_MS = 1_000;
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------------ helpers
|
|
54
|
+
|
|
55
|
+
export function regExe(env) {
|
|
56
|
+
return path.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32', 'reg.exe');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function markerFile(ctx) {
|
|
60
|
+
return path.join(dataDir(ctx), MARKER);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function setupLog(ctx) {
|
|
64
|
+
return path.join(dataDir(ctx), 'setup.log');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function linuxPrefix(ctx) {
|
|
68
|
+
return path.join(ctx.home, '.local');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function linuxKeep(ctx) {
|
|
72
|
+
return path.join(dataDir(ctx), 'linux');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function macAppsDir(ctx, system) {
|
|
76
|
+
return system ? ctx.systemApplications : path.join(ctx.home, 'Applications');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function windowsDefaultDir(ctx) {
|
|
80
|
+
return path.join(ctx.env.LOCALAPPDATA || path.join(ctx.home, 'AppData', 'Local'), 'Programs', 'Pandite');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function megabytes(bytes) {
|
|
84
|
+
return bytes ? ` (${(bytes / 1024 / 1024).toFixed(1)} MB)` : '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lastLine(text) {
|
|
88
|
+
return String(text ?? '').trim().split(/\r?\n/).pop() ?? '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exitText(r) {
|
|
92
|
+
return r.code === null || r.code === undefined ? `signal ${r.signal}` : `exit code ${r.code}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cleanup(dir) {
|
|
96
|
+
try {
|
|
97
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
|
|
98
|
+
} catch {
|
|
99
|
+
// A temporary folder that stays behind does no harm.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function run(ctx, command, args, options = {}) {
|
|
104
|
+
try {
|
|
105
|
+
return await ctx.run(command, args, options);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new InstallerError(`cannot start ${command}: ${error?.message ?? error}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** -1, 0 or 1; an installed version that is not semantic counts as older. */
|
|
112
|
+
function compareInstalled(installed, version) {
|
|
113
|
+
try {
|
|
114
|
+
return compareVersions(installed, version);
|
|
115
|
+
} catch {
|
|
116
|
+
return -1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ------------------------------------------------------------------- marker
|
|
121
|
+
|
|
122
|
+
/** The marker of the installed version: { version, ... }, or null. */
|
|
123
|
+
export function readMarker(ctx) {
|
|
124
|
+
try {
|
|
125
|
+
const marker = JSON.parse(readFileSync(markerFile(ctx), 'utf8'));
|
|
126
|
+
return marker && typeof marker.version === 'string' ? marker : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeMarker(ctx, marker) {
|
|
133
|
+
mkdirSync(dataDir(ctx), { recursive: true });
|
|
134
|
+
writeFileSync(markerFile(ctx), `${JSON.stringify(marker, null, 2)}\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Delete the per-user folder of the installer: marker, log, kept files. */
|
|
138
|
+
function forget(ctx) {
|
|
139
|
+
rmSync(dataDir(ctx), { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ----------------------------------------------------------------- Windows
|
|
143
|
+
|
|
144
|
+
/** The values of a .reg file that `reg export` writes (UTF-16 or UTF-8). */
|
|
145
|
+
export function parseRegFile(buf) {
|
|
146
|
+
const text = buf[0] === 0xff && buf[1] === 0xfe ? buf.toString('utf16le', 2) : buf.toString('utf8');
|
|
147
|
+
const values = {};
|
|
148
|
+
for (const line of text.split(/\r?\n/)) {
|
|
149
|
+
const m = /^"((?:[^"\\]|\\.)*)"="((?:[^"\\]|\\.)*)"$/.exec(line);
|
|
150
|
+
if (m) values[m[1].replace(/\\(.)/g, '$1')] = m[2].replace(/\\(.)/g, '$1');
|
|
151
|
+
}
|
|
152
|
+
return values;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function commandPath(command) {
|
|
156
|
+
const text = String(command ?? '').trim();
|
|
157
|
+
const quoted = /^"([^"]+)"/.exec(text);
|
|
158
|
+
return quoted ? quoted[1] : text.split(/\s+\//)[0];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The uninstall entry of the setup program: { version, location,
|
|
163
|
+
* uninstaller }, or null. `reg export` reads it without administrator
|
|
164
|
+
* rights and writes Unicode, so paths with any characters survive.
|
|
165
|
+
*/
|
|
166
|
+
async function windowsEntry(ctx) {
|
|
167
|
+
const dir = mkdtempSync(path.join(ctx.tmpdir, 'pandite-installer-'));
|
|
168
|
+
try {
|
|
169
|
+
for (const root of REGISTRY_ROOTS) {
|
|
170
|
+
const file = path.join(dir, `${root}.reg`);
|
|
171
|
+
const r = await run(ctx, regExe(ctx.env), ['export', `${root}\\${UNINSTALL_KEY}`, file, '/y'], { capture: true });
|
|
172
|
+
if (r.code !== 0 || !existsSync(file)) continue;
|
|
173
|
+
const values = parseRegFile(readFileSync(file));
|
|
174
|
+
const uninstaller = commandPath(values.UninstallString) || null;
|
|
175
|
+
const location = (values.InstallLocation || (uninstaller ? path.dirname(uninstaller) : '')).replace(/[\\/]+$/, '');
|
|
176
|
+
if (!uninstaller && !location) continue;
|
|
177
|
+
return { version: values.DisplayVersion || null, location, uninstaller };
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
} finally {
|
|
181
|
+
cleanup(dir);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------ installed
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The installed Pandite: { version, location, ... }, or null. On Windows
|
|
189
|
+
* the uninstall entry of the setup program decides; elsewhere the marker
|
|
190
|
+
* and the installed program decide.
|
|
191
|
+
*/
|
|
192
|
+
export async function installedState(ctx) {
|
|
193
|
+
const marker = readMarker(ctx);
|
|
194
|
+
if (ctx.platform === 'win32') {
|
|
195
|
+
const entry = await windowsEntry(ctx);
|
|
196
|
+
return entry ? { ...entry, version: entry.version ?? marker?.version ?? null } : null;
|
|
197
|
+
}
|
|
198
|
+
if (!marker) return null;
|
|
199
|
+
if (ctx.platform === 'darwin') {
|
|
200
|
+
if (!marker.location || !existsSync(marker.location)) return null;
|
|
201
|
+
return { version: marker.version, location: marker.location, system: Boolean(marker.system) };
|
|
202
|
+
}
|
|
203
|
+
const prefix = marker.prefix || linuxPrefix(ctx);
|
|
204
|
+
const bin = path.join(prefix, 'bin', 'pandite');
|
|
205
|
+
return existsSync(bin) ? { version: marker.version, location: bin, prefix } : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ------------------------------------------------------------ installers
|
|
209
|
+
|
|
210
|
+
async function installSetup(ctx, { file, release }) {
|
|
211
|
+
mkdirSync(dataDir(ctx), { recursive: true });
|
|
212
|
+
const log = setupLog(ctx);
|
|
213
|
+
rmSync(log, { force: true });
|
|
214
|
+
ctx.info('running the setup program for the current user');
|
|
215
|
+
const r = await run(ctx, file, [...SETUP_ARGS, `/LOG=${log}`]);
|
|
216
|
+
if (r.code !== 0) throw new InstallerError(`the setup program failed with ${exitText(r)}. Its log is ${log}`);
|
|
217
|
+
const entry = await windowsEntry(ctx);
|
|
218
|
+
const location = entry?.location || windowsDefaultDir(ctx);
|
|
219
|
+
return {
|
|
220
|
+
marker: { location },
|
|
221
|
+
message: `Pandite ${release.version} installed in ${location}. Start it from the Start menu.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function macStage(dir, system) {
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(dir, { recursive: true });
|
|
228
|
+
return mkdtempSync(path.join(dir, '.pandite-installer-'));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const hint = system ? ' Use an administrator account, or install without --system.' : '';
|
|
231
|
+
throw new InstallerError(`cannot write to ${dir}: ${error?.code ?? error?.message}.${hint}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Move `stage`/pandite.app to `dir`, in place of an older copy. */
|
|
236
|
+
function placeApp(stage, dir, release, system) {
|
|
237
|
+
const app = path.join(stage, MAC_APP);
|
|
238
|
+
const exe = path.join(app, 'Contents', 'MacOS', 'pandite');
|
|
239
|
+
if (!existsSync(exe)) throw new InstallerError(`the release file holds no ${MAC_APP}/Contents/MacOS/pandite`);
|
|
240
|
+
chmodSync(exe, 0o755);
|
|
241
|
+
const dest = path.join(dir, MAC_APP);
|
|
242
|
+
const old = path.join(stage, `old-${MAC_APP}`);
|
|
243
|
+
if (existsSync(dest)) renameSync(dest, old);
|
|
244
|
+
try {
|
|
245
|
+
renameSync(app, dest);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (existsSync(old)) renameSync(old, dest);
|
|
248
|
+
throw new InstallerError(`cannot move ${MAC_APP} to ${dir}: ${error?.message ?? error}`);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
marker: { location: dest, system },
|
|
252
|
+
message: `Pandite ${release.version} installed in ${dest}. Start it from Launchpad or with: open "${dest}"`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function installAppZip(ctx, { file, release, system }) {
|
|
257
|
+
const dir = macAppsDir(ctx, system);
|
|
258
|
+
const stage = macStage(dir, system);
|
|
259
|
+
try {
|
|
260
|
+
unzip(readFileSync(file), stage);
|
|
261
|
+
return placeApp(stage, dir, release, system);
|
|
262
|
+
} finally {
|
|
263
|
+
cleanup(stage);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function installDmg(ctx, { file, work, release, system }) {
|
|
268
|
+
const mount = path.join(work, 'mount');
|
|
269
|
+
mkdirSync(mount);
|
|
270
|
+
const attach = await run(ctx, HDIUTIL, ['attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mount, file], {
|
|
271
|
+
capture: true,
|
|
272
|
+
});
|
|
273
|
+
if (attach.code !== 0) {
|
|
274
|
+
throw new InstallerError(`hdiutil cannot mount ${path.basename(file)}: ${lastLine(attach.stderr) || exitText(attach)}`);
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
if (!existsSync(path.join(mount, MAC_APP))) throw new InstallerError(`${path.basename(file)} holds no ${MAC_APP}`);
|
|
278
|
+
const dir = macAppsDir(ctx, system);
|
|
279
|
+
const stage = macStage(dir, system);
|
|
280
|
+
try {
|
|
281
|
+
cpSync(path.join(mount, MAC_APP), path.join(stage, MAC_APP), { recursive: true, verbatimSymlinks: true });
|
|
282
|
+
return placeApp(stage, dir, release, system);
|
|
283
|
+
} finally {
|
|
284
|
+
cleanup(stage);
|
|
285
|
+
}
|
|
286
|
+
} finally {
|
|
287
|
+
await run(ctx, HDIUTIL, ['detach', mount, '-force'], { capture: true }).catch(() => {});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function installTarball(ctx, { file, work, release }) {
|
|
292
|
+
const unpacked = path.join(work, 'unpacked');
|
|
293
|
+
mkdirSync(unpacked);
|
|
294
|
+
untar(readFileSync(file), unpacked);
|
|
295
|
+
const top = path.join(unpacked, tarballTop(release.version));
|
|
296
|
+
const script = path.join(top, 'install.sh');
|
|
297
|
+
if (!existsSync(script) || !existsSync(path.join(top, 'pandite'))) {
|
|
298
|
+
throw new InstallerError(`${path.basename(file)} holds no ${tarballTop(release.version)}/install.sh and pandite`);
|
|
299
|
+
}
|
|
300
|
+
const prefix = linuxPrefix(ctx);
|
|
301
|
+
ctx.info(`running install.sh with PREFIX=${prefix}`);
|
|
302
|
+
const r = await run(ctx, SH, [script], { env: { ...ctx.env, PREFIX: prefix }, capture: true });
|
|
303
|
+
if (r.code !== 0) {
|
|
304
|
+
throw new InstallerError(`install.sh failed with ${exitText(r)}: ${lastLine(r.stderr) || lastLine(r.stdout)}`);
|
|
305
|
+
}
|
|
306
|
+
// install.sh --uninstall needs the script and the icon folders beside it.
|
|
307
|
+
const keep = linuxKeep(ctx);
|
|
308
|
+
rmSync(keep, { recursive: true, force: true });
|
|
309
|
+
mkdirSync(keep, { recursive: true });
|
|
310
|
+
cpSync(script, path.join(keep, 'install.sh'));
|
|
311
|
+
chmodSync(path.join(keep, 'install.sh'), 0o755);
|
|
312
|
+
if (existsSync(path.join(top, 'share'))) cpSync(path.join(top, 'share'), path.join(keep, 'share'), { recursive: true });
|
|
313
|
+
const bin = path.join(prefix, 'bin', 'pandite');
|
|
314
|
+
return {
|
|
315
|
+
marker: { prefix, location: bin },
|
|
316
|
+
message: `Pandite ${release.version} installed in ${bin}. Start it from the application menu or with: ${bin}`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const INSTALL = { setup: installSetup, 'app-zip': installAppZip, dmg: installDmg, tarball: installTarball };
|
|
321
|
+
|
|
322
|
+
/** The steps of each kind of release file, for --dry-run. */
|
|
323
|
+
function plan(ctx, kind, { file, temp, release, system }) {
|
|
324
|
+
if (kind === 'setup') {
|
|
325
|
+
return [
|
|
326
|
+
`Would run: ${file} ${[...SETUP_ARGS, `/LOG=${setupLog(ctx)}`].join(' ')}`,
|
|
327
|
+
`Would install in ${windowsDefaultDir(ctx)} (or the folder of an earlier install), for the current user, without administrator rights`,
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
const dest = path.join(macAppsDir(ctx, system), MAC_APP);
|
|
331
|
+
if (kind === 'app-zip') return [`Would unpack ${MAC_APP} from ${path.basename(file)} and move it to ${dest}, in place of an older copy`];
|
|
332
|
+
if (kind === 'dmg') return [`Would mount ${path.basename(file)} with ${HDIUTIL} and copy ${MAC_APP} to ${dest}, in place of an older copy`];
|
|
333
|
+
const script = path.join(temp, 'unpacked', tarballTop(release.version), 'install.sh');
|
|
334
|
+
return [
|
|
335
|
+
`Would unpack ${path.basename(file)} and run: PREFIX=${linuxPrefix(ctx)} ${SH} ${script}`,
|
|
336
|
+
`Would keep a copy of install.sh in ${linuxKeep(ctx)} for the uninstall`,
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------- commands
|
|
341
|
+
|
|
342
|
+
/** Download, check and install `release`. */
|
|
343
|
+
async function deliver(ctx, release, opts, installed) {
|
|
344
|
+
const available = release.assets.map((a) => a.name);
|
|
345
|
+
const pick = chooseAsset({ platform: ctx.platform, arch: ctx.arch, glibc: ctx.glibc, version: release.version, available });
|
|
346
|
+
const asset = release.assets.find((a) => a.name === pick.name);
|
|
347
|
+
const sums = release.assets.find((a) => a.name === SUMS_FILE);
|
|
348
|
+
if (!sums) {
|
|
349
|
+
throw new InstallerError(`release ${release.tag} has no ${SUMS_FILE}, so its files cannot be checked. Nothing was installed.`);
|
|
350
|
+
}
|
|
351
|
+
const system = ctx.platform === 'darwin' && Boolean(opts.system || installed?.system);
|
|
352
|
+
const label = `Pandite ${release.version}${release.latest ? ' (latest release)' : ''} for ${systemName(ctx.platform, ctx.arch)}`;
|
|
353
|
+
|
|
354
|
+
if (opts.dryRun) {
|
|
355
|
+
const temp = path.join(ctx.tmpdir, 'pandite-installer-XXXXXX');
|
|
356
|
+
const file = path.join(temp, pick.name);
|
|
357
|
+
ctx.out(label);
|
|
358
|
+
ctx.out(`Would download ${asset.url}${megabytes(asset.size)}`);
|
|
359
|
+
ctx.out(`Would download ${sums.url} and check the SHA-256 of ${pick.name} against it`);
|
|
360
|
+
for (const line of plan(ctx, pick.kind, { file, temp, release, system })) ctx.out(line);
|
|
361
|
+
ctx.out(`Would write the version to ${markerFile(ctx)}`);
|
|
362
|
+
ctx.out('Dry run: nothing was downloaded, installed or changed.');
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
ctx.info(label);
|
|
367
|
+
const work = mkdtempSync(path.join(ctx.tmpdir, 'pandite-installer-'));
|
|
368
|
+
try {
|
|
369
|
+
const got = await getText(sums.url, ctx.net, { accept: 'application/octet-stream' });
|
|
370
|
+
if (got.status !== 200) throw new InstallerError(`download of ${sums.url} failed: HTTP ${got.status}`);
|
|
371
|
+
const expected = parseSums(got.text).get(pick.name);
|
|
372
|
+
if (!expected) {
|
|
373
|
+
throw new InstallerError(`${SUMS_FILE} of release ${release.tag} does not list ${pick.name}. Nothing was installed.`);
|
|
374
|
+
}
|
|
375
|
+
ctx.info(`downloading ${asset.url}${megabytes(asset.size)}`);
|
|
376
|
+
const file = path.join(work, pick.name);
|
|
377
|
+
const { sha256 } = await download(asset.url, file, ctx.net);
|
|
378
|
+
if (sha256 !== expected) {
|
|
379
|
+
rmSync(file, { force: true });
|
|
380
|
+
throw new InstallerError(
|
|
381
|
+
`SHA-256 mismatch for ${pick.name}: ${SUMS_FILE} lists ${expected}, the download has ${sha256}. ` +
|
|
382
|
+
'The download was deleted. Nothing was installed.',
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
ctx.info(`the SHA-256 of ${pick.name} matches ${SUMS_FILE}`);
|
|
386
|
+
const done = await INSTALL[pick.kind](ctx, { file, work, release, system });
|
|
387
|
+
writeMarker(ctx, { version: release.version, asset: pick.name, sha256, ...done.marker });
|
|
388
|
+
ctx.out(done.message);
|
|
389
|
+
return 0;
|
|
390
|
+
} finally {
|
|
391
|
+
cleanup(work);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function alreadyInstalled(installed) {
|
|
396
|
+
return `Pandite ${installed.version} is already installed in ${installed.location}. To install it again, add --force.`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** `install`: the latest release, or `opts.version`. */
|
|
400
|
+
export async function installCommand(ctx, opts) {
|
|
401
|
+
const installed = await installedState(ctx);
|
|
402
|
+
if (opts.version && installed && !opts.force && compareInstalled(installed.version, opts.version) === 0) {
|
|
403
|
+
ctx.out(alreadyInstalled(installed));
|
|
404
|
+
return 0;
|
|
405
|
+
}
|
|
406
|
+
const release = await getRelease(opts.version, ctx.net);
|
|
407
|
+
if (!opts.version && installed && !opts.force) {
|
|
408
|
+
const order = compareInstalled(installed.version, release.version);
|
|
409
|
+
if (order === 0) {
|
|
410
|
+
ctx.out(alreadyInstalled(installed));
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
if (order > 0) {
|
|
414
|
+
ctx.out(
|
|
415
|
+
`Pandite ${installed.version} is installed in ${installed.location}. It is newer than the latest release ` +
|
|
416
|
+
`${release.version}. To install ${release.version}, run: npx pandite install -v ${release.version}`,
|
|
417
|
+
);
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return deliver(ctx, release, opts, installed);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** `update`: the latest release, when it is newer than the installed one. */
|
|
425
|
+
export async function updateCommand(ctx, opts) {
|
|
426
|
+
const installed = await installedState(ctx);
|
|
427
|
+
const release = await getRelease(null, ctx.net);
|
|
428
|
+
if (installed && !opts.force && compareInstalled(installed.version, release.version) >= 0) {
|
|
429
|
+
ctx.out(`Pandite ${installed.version} is already up to date (latest release: ${release.version}).`);
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
if (installed) ctx.info(`updating Pandite ${installed.version ?? '(unknown version)'} to ${release.version}`);
|
|
433
|
+
else ctx.info(`Pandite is not installed; installing the latest release ${release.version}`);
|
|
434
|
+
return deliver(ctx, release, opts, installed);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function notInstalled(ctx, opts, sentence) {
|
|
438
|
+
if (!opts.dryRun) forget(ctx);
|
|
439
|
+
ctx.out(`${sentence} Nothing was removed.`);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function dryRunEnd(ctx) {
|
|
444
|
+
ctx.out(`Would delete ${dataDir(ctx)}`);
|
|
445
|
+
ctx.out('Dry run: nothing was changed.');
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function uninstallWindows(ctx, opts) {
|
|
450
|
+
const entry = await windowsEntry(ctx);
|
|
451
|
+
if (!entry) return notInstalled(ctx, opts, 'Pandite is not installed for this user: the setup program registered no uninstaller.');
|
|
452
|
+
if (!entry.uninstaller || !existsSync(entry.uninstaller)) {
|
|
453
|
+
throw new InstallerError(
|
|
454
|
+
`the registered uninstaller ${entry.uninstaller ?? '(none)'} does not exist. Remove Pandite in Settings, Apps, Installed apps.`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (opts.dryRun) {
|
|
458
|
+
ctx.out(`Would run: ${entry.uninstaller} ${UNINSTALL_ARGS.join(' ')}`);
|
|
459
|
+
return dryRunEnd(ctx);
|
|
460
|
+
}
|
|
461
|
+
ctx.info('running the uninstaller');
|
|
462
|
+
const r = await run(ctx, entry.uninstaller, UNINSTALL_ARGS);
|
|
463
|
+
if (r.code !== 0) throw new InstallerError(`the uninstaller failed with ${exitText(r)}`);
|
|
464
|
+
// The uninstaller finishes in a copy of itself: wait for its entry to go.
|
|
465
|
+
for (let waited = 0; await windowsEntry(ctx); waited += UNINSTALL_POLL_MS) {
|
|
466
|
+
if (waited >= UNINSTALL_WAIT_MS) {
|
|
467
|
+
throw new InstallerError(`the uninstaller did not finish within ${UNINSTALL_WAIT_MS / 1000} seconds`);
|
|
468
|
+
}
|
|
469
|
+
await ctx.sleep(UNINSTALL_POLL_MS);
|
|
470
|
+
}
|
|
471
|
+
forget(ctx);
|
|
472
|
+
ctx.out(`Pandite removed from ${entry.location}.`);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function uninstallMac(ctx, opts) {
|
|
477
|
+
const marker = readMarker(ctx);
|
|
478
|
+
const location = marker?.location || path.join(macAppsDir(ctx, opts.system), MAC_APP);
|
|
479
|
+
if (path.basename(location) !== MAC_APP || !existsSync(location)) {
|
|
480
|
+
return notInstalled(ctx, opts, `Pandite is not installed: ${location} does not exist.`);
|
|
481
|
+
}
|
|
482
|
+
if (opts.dryRun) {
|
|
483
|
+
ctx.out(`Would delete ${location}`);
|
|
484
|
+
return dryRunEnd(ctx);
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
rmSync(location, { recursive: true, force: true });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
throw new InstallerError(`cannot delete ${location}: ${error?.code ?? error?.message}`);
|
|
490
|
+
}
|
|
491
|
+
forget(ctx);
|
|
492
|
+
ctx.out(`Pandite removed from ${location}.`);
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function uninstallLinux(ctx, opts) {
|
|
497
|
+
const marker = readMarker(ctx);
|
|
498
|
+
const script = path.join(linuxKeep(ctx), 'install.sh');
|
|
499
|
+
const prefix = marker?.prefix || linuxPrefix(ctx);
|
|
500
|
+
if (!existsSync(script)) {
|
|
501
|
+
return notInstalled(ctx, opts, `Pandite is not installed by this installer: ${linuxKeep(ctx)} holds no install.sh.`);
|
|
502
|
+
}
|
|
503
|
+
if (opts.dryRun) {
|
|
504
|
+
ctx.out(`Would run: PREFIX=${prefix} ${SH} ${script} --uninstall`);
|
|
505
|
+
return dryRunEnd(ctx);
|
|
506
|
+
}
|
|
507
|
+
const r = await run(ctx, SH, [script, '--uninstall'], { env: { ...ctx.env, PREFIX: prefix }, capture: true });
|
|
508
|
+
if (r.code !== 0) {
|
|
509
|
+
throw new InstallerError(`install.sh --uninstall failed with ${exitText(r)}: ${lastLine(r.stderr) || lastLine(r.stdout)}`);
|
|
510
|
+
}
|
|
511
|
+
forget(ctx);
|
|
512
|
+
ctx.out(`Pandite removed from ${prefix}.`);
|
|
513
|
+
return 0;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** `uninstall`: remove Pandite and the marker. */
|
|
517
|
+
export async function uninstallCommand(ctx, opts) {
|
|
518
|
+
if (ctx.platform === 'win32') return uninstallWindows(ctx, opts);
|
|
519
|
+
if (ctx.platform === 'darwin') return uninstallMac(ctx, opts);
|
|
520
|
+
return uninstallLinux(ctx, opts);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** `--list`: the release versions, newest first. */
|
|
524
|
+
export async function listCommand(ctx) {
|
|
525
|
+
const releases = await listReleases(ctx.net);
|
|
526
|
+
if (!releases.length) throw new InstallerError(`no Pandite release found at ${RELEASES_URL}`);
|
|
527
|
+
for (const r of releases) ctx.out(r.prerelease ? `${r.version} (pre-release)` : r.version);
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
package/lib/net.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// HTTPS requests through an injected fetch function: redirects to HTTPS
|
|
2
|
+
// addresses only, a time limit without data, size limits, and the SHA-256
|
|
3
|
+
// of each download.
|
|
4
|
+
//
|
|
5
|
+
// `net` = { fetch, userAgent, idleMs }. The fetch function has the
|
|
6
|
+
// signature of the global fetch of Node.js 18 and later.
|
|
7
|
+
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createWriteStream, rmSync } from 'node:fs';
|
|
10
|
+
import { Readable, Transform } from 'node:stream';
|
|
11
|
+
import { pipeline } from 'node:stream/promises';
|
|
12
|
+
import { InstallerError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
export const MAX_REDIRECTS = 5;
|
|
15
|
+
export const MAX_DOWNLOAD_BYTES = 1024 * 1024 * 1024;
|
|
16
|
+
export const MAX_TEXT_BYTES = 16 * 1024 * 1024;
|
|
17
|
+
export const IDLE_TIMEOUT_MS = 60_000;
|
|
18
|
+
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
|
|
19
|
+
|
|
20
|
+
function describe(error) {
|
|
21
|
+
const cause = error?.cause;
|
|
22
|
+
const code = cause?.code ?? error?.code;
|
|
23
|
+
const text = cause?.message ?? error?.message ?? String(error);
|
|
24
|
+
return code && !String(text).includes(code) ? `${code} ${text}` : String(text);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Aborts `controller` after `ms` milliseconds without a call to touch(). */
|
|
28
|
+
function idleTimer(controller, ms) {
|
|
29
|
+
const state = { expired: false };
|
|
30
|
+
let timer = null;
|
|
31
|
+
const touch = () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
timer = setTimeout(() => {
|
|
34
|
+
state.expired = true;
|
|
35
|
+
controller.abort();
|
|
36
|
+
}, ms);
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
};
|
|
39
|
+
touch();
|
|
40
|
+
return { state, touch, stop: () => clearTimeout(timer) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function discard(res) {
|
|
44
|
+
try {
|
|
45
|
+
await res.body?.cancel?.();
|
|
46
|
+
} catch {
|
|
47
|
+
// The body is not needed.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GET `url`, following at most MAX_REDIRECTS redirects, each to an HTTPS
|
|
53
|
+
* address only. Resolves { res, url } for the first answer that is not a
|
|
54
|
+
* redirect. The caller reads or discards the body.
|
|
55
|
+
*/
|
|
56
|
+
async function get(url, net, { headers, controller, idle }) {
|
|
57
|
+
const idleMs = net.idleMs ?? IDLE_TIMEOUT_MS;
|
|
58
|
+
let current = new URL(url);
|
|
59
|
+
for (let hops = 0; ; hops += 1) {
|
|
60
|
+
if (current.protocol !== 'https:') {
|
|
61
|
+
const what = hops === 0 ? current.href : `the redirect to ${current.href}`;
|
|
62
|
+
throw new InstallerError(`refusing ${what}: only HTTPS addresses are allowed`);
|
|
63
|
+
}
|
|
64
|
+
let res;
|
|
65
|
+
try {
|
|
66
|
+
res = await net.fetch(current.href, {
|
|
67
|
+
headers: { 'user-agent': net.userAgent, ...headers },
|
|
68
|
+
redirect: 'manual',
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (idle.state.expired) throw new InstallerError(`no answer from ${current.host} for ${idleMs / 1000} seconds`);
|
|
73
|
+
throw new InstallerError(`cannot reach ${current.host}: ${describe(error)}. Check the network connection.`);
|
|
74
|
+
}
|
|
75
|
+
idle.touch();
|
|
76
|
+
if (REDIRECT_CODES.has(res.status)) {
|
|
77
|
+
await discard(res);
|
|
78
|
+
const location = res.headers.get('location');
|
|
79
|
+
if (!location) throw new InstallerError(`${current.href} answered HTTP ${res.status} without a Location header`);
|
|
80
|
+
if (hops >= MAX_REDIRECTS) throw new InstallerError(`more than ${MAX_REDIRECTS} redirects for ${url}`);
|
|
81
|
+
current = new URL(location, current);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
return { res, url: current.href };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** GET a text resource: { status, headers, text }. Any HTTP status resolves. */
|
|
89
|
+
export async function getText(url, net, headers = {}) {
|
|
90
|
+
const idleMs = net.idleMs ?? IDLE_TIMEOUT_MS;
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const idle = idleTimer(controller, idleMs);
|
|
93
|
+
try {
|
|
94
|
+
const { res } = await get(url, net, { headers, controller, idle });
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
try {
|
|
98
|
+
for await (const chunk of res.body ?? []) {
|
|
99
|
+
bytes += chunk.length;
|
|
100
|
+
if (bytes > MAX_TEXT_BYTES) {
|
|
101
|
+
await discard(res);
|
|
102
|
+
throw new InstallerError(`${url} sent more than ${MAX_TEXT_BYTES} bytes`);
|
|
103
|
+
}
|
|
104
|
+
idle.touch();
|
|
105
|
+
chunks.push(Buffer.from(chunk));
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof InstallerError) throw error;
|
|
109
|
+
if (idle.state.expired) throw new InstallerError(`no data from ${new URL(url).host} for ${idleMs / 1000} seconds`);
|
|
110
|
+
throw new InstallerError(`reading ${url} failed: ${describe(error)}`);
|
|
111
|
+
}
|
|
112
|
+
return { status: res.status, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') };
|
|
113
|
+
} finally {
|
|
114
|
+
idle.stop();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Download `url` into the new file `file`. Resolves { sha256, bytes }.
|
|
120
|
+
* Deletes the file when the download fails.
|
|
121
|
+
*/
|
|
122
|
+
export async function download(url, file, net, { maxBytes = MAX_DOWNLOAD_BYTES } = {}) {
|
|
123
|
+
const idleMs = net.idleMs ?? IDLE_TIMEOUT_MS;
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const idle = idleTimer(controller, idleMs);
|
|
126
|
+
try {
|
|
127
|
+
const { res } = await get(url, net, { headers: { accept: 'application/octet-stream' }, controller, idle });
|
|
128
|
+
if (res.status !== 200 || !res.body) {
|
|
129
|
+
await discard(res);
|
|
130
|
+
const hint = res.status === 404 ? ' (the file does not exist)' : '';
|
|
131
|
+
throw new InstallerError(`download of ${url} failed: HTTP ${res.status}${hint}`);
|
|
132
|
+
}
|
|
133
|
+
const hash = createHash('sha256');
|
|
134
|
+
let bytes = 0;
|
|
135
|
+
const meter = new Transform({
|
|
136
|
+
transform(chunk, _encoding, done) {
|
|
137
|
+
bytes += chunk.length;
|
|
138
|
+
if (bytes > maxBytes) {
|
|
139
|
+
done(new InstallerError(`${url} is larger than ${maxBytes} bytes`));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
idle.touch();
|
|
143
|
+
hash.update(chunk);
|
|
144
|
+
done(null, chunk);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
try {
|
|
148
|
+
await pipeline(Readable.from(res.body), meter, createWriteStream(file, { flags: 'wx' }));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error?.code !== 'EEXIST') rmSync(file, { force: true });
|
|
151
|
+
if (error instanceof InstallerError) throw error;
|
|
152
|
+
if (idle.state.expired) throw new InstallerError(`no data from ${new URL(url).host} for ${idleMs / 1000} seconds`);
|
|
153
|
+
throw new InstallerError(`download of ${url} failed: ${describe(error)}`);
|
|
154
|
+
}
|
|
155
|
+
return { sha256: hash.digest('hex'), bytes };
|
|
156
|
+
} finally {
|
|
157
|
+
idle.stop();
|
|
158
|
+
}
|
|
159
|
+
}
|