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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Rizos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pandite installer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Installs [Pandite](https://github.com/xls/pandite), a fast offline image
|
|
4
|
+
viewer, from its GitHub releases. Needs Node.js 18 or later. No
|
|
5
|
+
dependencies, no install scripts.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx pandite
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
| Command | Action |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `npx pandite` | Install the latest release. |
|
|
16
|
+
| `npx pandite install -v 0.2.0` | Install that release. `0.2.0` and `v0.2.0` are equal. |
|
|
17
|
+
| `npx pandite update` | Install the latest release when it is newer than the installed one. Install it when Pandite is not installed. |
|
|
18
|
+
| `npx pandite uninstall` | Remove Pandite. `--uninstall` is the same. |
|
|
19
|
+
| `npx pandite --list` | Print the release versions, newest first. |
|
|
20
|
+
|
|
21
|
+
| Option | Action |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `-v`, `--version <version>` | The Pandite version to install. |
|
|
24
|
+
| `--dry-run` | Print the downloads and the steps. Download and change nothing. |
|
|
25
|
+
| `--force` | Install again when the same version is installed. |
|
|
26
|
+
| `--system` | macOS: install in `/Applications`, not `~/Applications`. |
|
|
27
|
+
| `--installer-version` | Print the version of this installer. |
|
|
28
|
+
| `-h`, `--help` | Print the help. |
|
|
29
|
+
|
|
30
|
+
`pandite-installer` is the same command. Exit codes: 0 done, 1 error,
|
|
31
|
+
2 wrong command line.
|
|
32
|
+
|
|
33
|
+
## Downloads and checks
|
|
34
|
+
|
|
35
|
+
1. The installer reads the release from the GitHub API:
|
|
36
|
+
`https://api.github.com/repos/xls/pandite/releases`.
|
|
37
|
+
2. It downloads one release file for your system and `SHA256SUMS.txt` of
|
|
38
|
+
the same release. It uses HTTPS only and follows redirects to HTTPS
|
|
39
|
+
addresses only.
|
|
40
|
+
3. It compares the SHA-256 of the file with `SHA256SUMS.txt`. When the
|
|
41
|
+
SHA-256 differs, or `SHA256SUMS.txt` does not list the file, it deletes
|
|
42
|
+
the file and installs nothing.
|
|
43
|
+
|
|
44
|
+
The requests carry the User-Agent `pandite-installer/<version>` and no
|
|
45
|
+
token. The installer sends no other data.
|
|
46
|
+
|
|
47
|
+
## Systems and install locations
|
|
48
|
+
|
|
49
|
+
| System | Release file | Install |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Windows x64, ARM64 | `pandite-<version>-<target>-setup.exe` | Runs the setup program silently for the current user, without administrator rights: `%LOCALAPPDATA%\Programs\Pandite`, Start menu, image file associations. |
|
|
52
|
+
| macOS 11 or later | `pandite-<version>-universal-apple-darwin.zip`, else the `.dmg` | Copies `pandite.app` to `~/Applications`, or to `/Applications` with `--system`. Replaces an older copy. |
|
|
53
|
+
| Linux x64, glibc 2.35 or later | `pandite-<version>-x86_64-unknown-linux-gnu.tar.gz` | Runs the `install.sh` of the tarball with `PREFIX=~/.local`: `~/.local/bin/pandite`, desktop entry, icons. |
|
|
54
|
+
|
|
55
|
+
Other systems get a "not supported" message.
|
|
56
|
+
|
|
57
|
+
The installer keeps the installed version in `installed.json` in its
|
|
58
|
+
per-user folder:
|
|
59
|
+
|
|
60
|
+
- Windows: `%LOCALAPPDATA%\pandite-installer`
|
|
61
|
+
- macOS: `~/Library/Application Support/pandite-installer`
|
|
62
|
+
- Linux: `$XDG_DATA_HOME/pandite-installer` or
|
|
63
|
+
`~/.local/share/pandite-installer`. The folder also keeps `install.sh`
|
|
64
|
+
for the uninstall.
|
|
65
|
+
|
|
66
|
+
On Windows, the uninstall runs the uninstaller that the setup program
|
|
67
|
+
registered. On Linux, it runs the kept `install.sh --uninstall`. On macOS,
|
|
68
|
+
it deletes the app.
|
|
69
|
+
|
|
70
|
+
## Notes
|
|
71
|
+
|
|
72
|
+
- npx keeps a copy of the installer. To use the newest installer, run
|
|
73
|
+
`npx pandite@latest`.
|
|
74
|
+
- `npm install -g pandite` adds a `pandite` command that is this installer,
|
|
75
|
+
not the image viewer. Use npx.
|
|
76
|
+
|
|
77
|
+
License: MIT.
|
package/bin/pandite.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Pandite installer: npx pandite [install|update|uninstall] [options]
|
|
3
|
+
// Run with --help for the commands and options.
|
|
4
|
+
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
|
|
7
|
+
const major = Number.parseInt(process.versions.node, 10);
|
|
8
|
+
if (major < 18) {
|
|
9
|
+
process.stderr.write(`pandite-installer: Node.js 18 or later is required; this is Node.js ${process.versions.node}\n`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { main } = await import('../lib/cli.js');
|
|
14
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/lib/archive.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Unpack zip and .tar.gz release files with the Node.js standard library.
|
|
2
|
+
// Every entry must stay inside the target folder; links are refused.
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { gunzipSync, inflateRawSync } from 'node:zlib';
|
|
7
|
+
import { InstallerError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
/** The parts of an archive entry name; refuses names that leave the folder. */
|
|
10
|
+
function entryParts(name) {
|
|
11
|
+
const clean = name.replace(/\\/g, '/');
|
|
12
|
+
if (clean.startsWith('/') || /^[A-Za-z]:/.test(clean)) throw new InstallerError(`archive entry with an absolute path: ${name}`);
|
|
13
|
+
const parts = clean.split('/').filter((p) => p !== '' && p !== '.');
|
|
14
|
+
if (parts.some((p) => p === '..' || /[:\0]/.test(p))) throw new InstallerError(`archive entry outside its folder: ${name}`);
|
|
15
|
+
return parts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeEntry(dest, parts, data, mode) {
|
|
19
|
+
const file = path.join(dest, ...parts);
|
|
20
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
21
|
+
// "wx": an entry never replaces another entry or follows a link.
|
|
22
|
+
writeFileSync(file, data, { mode: mode || 0o644, flag: 'wx' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Unpack a zip file (stored or deflated entries) into `dest`. Returns the file names. */
|
|
26
|
+
export function unzip(buf, dest) {
|
|
27
|
+
let eocd = -1;
|
|
28
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i -= 1) {
|
|
29
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
30
|
+
eocd = i;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (eocd < 0) throw new InstallerError('not a zip file');
|
|
35
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
36
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
37
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
38
|
+
if (count === 0xffff || cdOffset === 0xffffffff) throw new InstallerError('zip64 files are not supported');
|
|
39
|
+
if (cdOffset + cdSize > eocd) throw new InstallerError('damaged zip file');
|
|
40
|
+
const files = [];
|
|
41
|
+
let p = cdOffset;
|
|
42
|
+
for (let n = 0; n < count; n += 1) {
|
|
43
|
+
if (p + 46 > eocd || buf.readUInt32LE(p) !== 0x02014b50) throw new InstallerError('damaged zip directory');
|
|
44
|
+
const madeBy = buf.readUInt16LE(p + 4);
|
|
45
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
46
|
+
const method = buf.readUInt16LE(p + 10);
|
|
47
|
+
const compressed = buf.readUInt32LE(p + 20);
|
|
48
|
+
const size = buf.readUInt32LE(p + 24);
|
|
49
|
+
const nameLength = buf.readUInt16LE(p + 28);
|
|
50
|
+
const extraLength = buf.readUInt16LE(p + 30);
|
|
51
|
+
const commentLength = buf.readUInt16LE(p + 32);
|
|
52
|
+
const external = buf.readUInt32LE(p + 38);
|
|
53
|
+
const local = buf.readUInt32LE(p + 42);
|
|
54
|
+
const name = buf.toString(flags & 0x800 ? 'utf8' : 'latin1', p + 46, p + 46 + nameLength);
|
|
55
|
+
p += 46 + nameLength + extraLength + commentLength;
|
|
56
|
+
if (flags & 0x1) throw new InstallerError(`encrypted zip entry: ${name}`);
|
|
57
|
+
const parts = entryParts(name);
|
|
58
|
+
// macOS keeps extended attributes in __MACOSX; the app needs none.
|
|
59
|
+
if (parts.length === 0 || parts[0] === '__MACOSX') continue;
|
|
60
|
+
const unixMode = madeBy >> 8 === 3 ? external >>> 16 : 0;
|
|
61
|
+
const type = unixMode & 0o170000;
|
|
62
|
+
if (name.endsWith('/') || type === 0o040000) {
|
|
63
|
+
mkdirSync(path.join(dest, ...parts), { recursive: true });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (type !== 0 && type !== 0o100000) throw new InstallerError(`zip entry is not a file or folder: ${name}`);
|
|
67
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) throw new InstallerError(`damaged zip entry: ${name}`);
|
|
68
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
69
|
+
const raw = buf.subarray(start, start + compressed);
|
|
70
|
+
if (raw.length !== compressed) throw new InstallerError(`damaged zip entry: ${name}`);
|
|
71
|
+
let data;
|
|
72
|
+
if (method === 0) data = raw;
|
|
73
|
+
else if (method === 8) data = inflateRawSync(raw);
|
|
74
|
+
else throw new InstallerError(`zip entry ${name} uses compression method ${method}`);
|
|
75
|
+
if (data.length !== size) throw new InstallerError(`zip entry ${name} has the wrong size`);
|
|
76
|
+
writeEntry(dest, parts, data, unixMode & 0o777);
|
|
77
|
+
files.push(parts.join('/'));
|
|
78
|
+
}
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function paxPath(data) {
|
|
83
|
+
let value = null;
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < data.length) {
|
|
86
|
+
const space = data.indexOf(0x20, i);
|
|
87
|
+
if (space < 0) break;
|
|
88
|
+
const length = Number.parseInt(data.toString('latin1', i, space), 10);
|
|
89
|
+
if (!(length > 0) || i + length > data.length) break;
|
|
90
|
+
const record = data.toString('utf8', space + 1, i + length - 1);
|
|
91
|
+
const eq = record.indexOf('=');
|
|
92
|
+
if (eq > 0 && record.slice(0, eq) === 'path') value = record.slice(eq + 1);
|
|
93
|
+
i += length;
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Unpack a .tar.gz file (ustar, GNU long names, pax paths) into `dest`. Returns the file names. */
|
|
99
|
+
export function untar(gz, dest) {
|
|
100
|
+
const buf = gunzipSync(gz);
|
|
101
|
+
const files = [];
|
|
102
|
+
let longName = null;
|
|
103
|
+
let p = 0;
|
|
104
|
+
while (p + 512 <= buf.length) {
|
|
105
|
+
const header = buf.subarray(p, p + 512);
|
|
106
|
+
if (header.every((b) => b === 0)) break;
|
|
107
|
+
const text = (start, length) => {
|
|
108
|
+
const field = header.subarray(start, start + length);
|
|
109
|
+
const end = field.indexOf(0);
|
|
110
|
+
return field.toString('utf8', 0, end < 0 ? length : end);
|
|
111
|
+
};
|
|
112
|
+
const octal = (start, length) => {
|
|
113
|
+
if (header[start] & 0x80) throw new InstallerError('tar entry too large');
|
|
114
|
+
const digits = text(start, length).trim();
|
|
115
|
+
return digits ? Number.parseInt(digits, 8) : 0;
|
|
116
|
+
};
|
|
117
|
+
const size = octal(124, 12);
|
|
118
|
+
const mode = octal(100, 8);
|
|
119
|
+
const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
|
|
120
|
+
let name = text(0, 100);
|
|
121
|
+
// POSIX ustar splits long names into prefix and name; GNU tar uses "L".
|
|
122
|
+
if (header.toString('latin1', 257, 263) === 'ustar\u0000') {
|
|
123
|
+
const prefix = text(345, 155);
|
|
124
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
125
|
+
}
|
|
126
|
+
const data = buf.subarray(p + 512, p + 512 + size);
|
|
127
|
+
if (data.length !== size) throw new InstallerError('damaged tar file');
|
|
128
|
+
p += 512 + Math.ceil(size / 512) * 512;
|
|
129
|
+
if (type === 'L') {
|
|
130
|
+
longName = data.toString('utf8').replace(/\0+$/, '');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (type === 'x') {
|
|
134
|
+
longName = paxPath(data) ?? longName;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (type === 'g') continue;
|
|
138
|
+
if (longName !== null) {
|
|
139
|
+
name = longName;
|
|
140
|
+
longName = null;
|
|
141
|
+
}
|
|
142
|
+
const parts = entryParts(name);
|
|
143
|
+
if (type === '5') {
|
|
144
|
+
if (parts.length) mkdirSync(path.join(dest, ...parts), { recursive: true });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (type !== '0' && type !== '7') throw new InstallerError(`tar entry is not a file or folder: ${name}`);
|
|
148
|
+
if (parts.length === 0) continue;
|
|
149
|
+
writeEntry(dest, parts, data, mode & 0o777);
|
|
150
|
+
files.push(parts.join('/'));
|
|
151
|
+
}
|
|
152
|
+
return files;
|
|
153
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The command line of the Pandite installer: parse the arguments, build the
|
|
2
|
+
// context from the real system (or from the test doubles in `deps`) and
|
|
3
|
+
// run one command. main() resolves the exit code.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { InstallerError, usageError } from './errors.js';
|
|
10
|
+
import { installCommand, listCommand, uninstallCommand, updateCommand } from './install.js';
|
|
11
|
+
import { checkSupported, detectGlibc } from './platform.js';
|
|
12
|
+
import { normalizeVersion } from './version.js';
|
|
13
|
+
|
|
14
|
+
export const INSTALLER_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
15
|
+
|
|
16
|
+
export const HELP = `Pandite installer ${INSTALLER_VERSION}
|
|
17
|
+
Installs Pandite, the fast offline image viewer, from its GitHub releases.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
npx pandite [install] [-v <version>] [--force] [--system] [--dry-run]
|
|
21
|
+
npx pandite update [--force] [--dry-run]
|
|
22
|
+
npx pandite uninstall [--dry-run]
|
|
23
|
+
npx pandite --list
|
|
24
|
+
|
|
25
|
+
Commands:
|
|
26
|
+
install install the latest release, or the release of -v
|
|
27
|
+
(the default command)
|
|
28
|
+
update install the latest release when it is newer than
|
|
29
|
+
the installed one; install it when Pandite is not
|
|
30
|
+
installed
|
|
31
|
+
uninstall, --uninstall remove Pandite
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-v, --version <version> the Pandite version to install: 0.1.0 or v0.1.0
|
|
35
|
+
--list print the Pandite versions on GitHub
|
|
36
|
+
--dry-run print the downloads and the steps; download and
|
|
37
|
+
change nothing
|
|
38
|
+
--force install again when the same version is installed
|
|
39
|
+
--system macOS: install in /Applications, not ~/Applications
|
|
40
|
+
--installer-version print the version of this installer
|
|
41
|
+
-h, --help print this help
|
|
42
|
+
|
|
43
|
+
pandite-installer is the same command.`;
|
|
44
|
+
|
|
45
|
+
const COMMANDS = new Set(['install', 'update', 'uninstall']);
|
|
46
|
+
const FLAGS = {
|
|
47
|
+
'--list': 'list',
|
|
48
|
+
'--dry-run': 'dryRun',
|
|
49
|
+
'--force': 'force',
|
|
50
|
+
'--system': 'system',
|
|
51
|
+
'--installer-version': 'installerVersion',
|
|
52
|
+
'-h': 'help',
|
|
53
|
+
'--help': 'help',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The options of a command line. Throws a usage error (exit code 2). */
|
|
57
|
+
export function parseArgs(argv) {
|
|
58
|
+
const opts = {
|
|
59
|
+
command: null,
|
|
60
|
+
version: null,
|
|
61
|
+
list: false,
|
|
62
|
+
dryRun: false,
|
|
63
|
+
force: false,
|
|
64
|
+
system: false,
|
|
65
|
+
installerVersion: false,
|
|
66
|
+
help: false,
|
|
67
|
+
};
|
|
68
|
+
const setCommand = (command) => {
|
|
69
|
+
if (opts.command && opts.command !== command) throw usageError(`choose one command, not ${opts.command} and ${command}`);
|
|
70
|
+
opts.command = command;
|
|
71
|
+
};
|
|
72
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
73
|
+
const arg = String(argv[i]);
|
|
74
|
+
const eq = arg.startsWith('-') ? arg.indexOf('=') : -1;
|
|
75
|
+
const name = eq > 0 ? arg.slice(0, eq) : arg;
|
|
76
|
+
if (name === '-v' || name === '--version') {
|
|
77
|
+
let value = eq > 0 ? arg.slice(eq + 1) : argv[i + 1];
|
|
78
|
+
if (eq < 0) i += 1;
|
|
79
|
+
if (value === undefined || value === '' || String(value).startsWith('-')) {
|
|
80
|
+
throw usageError(`${name} needs a Pandite version, such as ${name} 0.1.0 (for the version of the installer: --installer-version)`);
|
|
81
|
+
}
|
|
82
|
+
value = normalizeVersion(value);
|
|
83
|
+
if (opts.version && opts.version !== value) throw usageError(`choose one version, not ${opts.version} and ${value}`);
|
|
84
|
+
opts.version = value;
|
|
85
|
+
} else if (FLAGS[name]) {
|
|
86
|
+
if (eq > 0) throw usageError(`${name} takes no value`);
|
|
87
|
+
opts[FLAGS[name]] = true;
|
|
88
|
+
} else if (name === '--uninstall') {
|
|
89
|
+
if (eq > 0) throw usageError(`${name} takes no value`);
|
|
90
|
+
setCommand('uninstall');
|
|
91
|
+
} else if (arg.startsWith('-')) {
|
|
92
|
+
throw usageError(`unknown option ${arg}`);
|
|
93
|
+
} else if (COMMANDS.has(arg)) {
|
|
94
|
+
setCommand(arg);
|
|
95
|
+
} else {
|
|
96
|
+
throw usageError(`unknown command "${arg}". This is the Pandite installer; it opens no images`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (opts.version && (opts.command === 'update' || opts.command === 'uninstall')) {
|
|
100
|
+
throw usageError(`${opts.command} takes no version. To install a named version, run: npx pandite install -v ${opts.version}`);
|
|
101
|
+
}
|
|
102
|
+
return opts;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Start a program and wait for it: { code, signal, stdout, stderr }. */
|
|
106
|
+
export function runProcess(command, args, { env, capture = false } = {}) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const io = capture ? 'pipe' : 'ignore';
|
|
109
|
+
const child = spawn(command, args, { env, stdio: ['ignore', io, io], windowsHide: true });
|
|
110
|
+
const out = [];
|
|
111
|
+
const err = [];
|
|
112
|
+
child.stdout?.on('data', (chunk) => out.push(chunk));
|
|
113
|
+
child.stderr?.on('data', (chunk) => err.push(chunk));
|
|
114
|
+
child.once('error', reject);
|
|
115
|
+
child.once('close', (code, signal) => {
|
|
116
|
+
resolve({ code, signal, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The real system. Tests replace every key. */
|
|
122
|
+
export function defaultDeps() {
|
|
123
|
+
return {
|
|
124
|
+
platform: process.platform,
|
|
125
|
+
arch: process.arch,
|
|
126
|
+
glibc: undefined,
|
|
127
|
+
env: process.env,
|
|
128
|
+
home: os.homedir(),
|
|
129
|
+
tmpdir: os.tmpdir(),
|
|
130
|
+
systemApplications: '/Applications',
|
|
131
|
+
fetch: globalThis.fetch,
|
|
132
|
+
run: runProcess,
|
|
133
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
134
|
+
now: () => Date.now(),
|
|
135
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
136
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeContext(deps) {
|
|
141
|
+
const d = { ...defaultDeps(), ...deps };
|
|
142
|
+
const glibc = d.glibc !== undefined ? d.glibc : d.platform === 'linux' ? detectGlibc() : null;
|
|
143
|
+
return {
|
|
144
|
+
platform: d.platform,
|
|
145
|
+
arch: d.arch,
|
|
146
|
+
glibc,
|
|
147
|
+
env: d.env,
|
|
148
|
+
home: d.home,
|
|
149
|
+
tmpdir: d.tmpdir,
|
|
150
|
+
systemApplications: d.systemApplications,
|
|
151
|
+
run: d.run,
|
|
152
|
+
sleep: d.sleep,
|
|
153
|
+
net: { fetch: d.fetch, userAgent: `pandite-installer/${INSTALLER_VERSION}`, now: d.now },
|
|
154
|
+
out: d.out,
|
|
155
|
+
info: (line) => d.err(`pandite-installer: ${line}`),
|
|
156
|
+
error: (line) => d.err(`pandite-installer: ${line}`),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Run the installer with `argv`. Resolves the exit code: 0, 1, or 2 for a usage error. */
|
|
161
|
+
export async function main(argv, deps = {}) {
|
|
162
|
+
const ctx = makeContext(deps);
|
|
163
|
+
try {
|
|
164
|
+
const opts = parseArgs(argv);
|
|
165
|
+
if (opts.help) {
|
|
166
|
+
ctx.out(HELP);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (opts.installerVersion) {
|
|
170
|
+
ctx.out(INSTALLER_VERSION);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
if (typeof ctx.net.fetch !== 'function') throw new InstallerError('this Node.js has no fetch; use Node.js 18 or later');
|
|
174
|
+
if (opts.list) return await listCommand(ctx);
|
|
175
|
+
checkSupported(ctx);
|
|
176
|
+
if (opts.system && ctx.platform !== 'darwin') throw usageError('--system works on macOS only');
|
|
177
|
+
if (opts.command === 'update') return await updateCommand(ctx, opts);
|
|
178
|
+
if (opts.command === 'uninstall') return await uninstallCommand(ctx, opts);
|
|
179
|
+
return await installCommand(ctx, opts);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof InstallerError) {
|
|
182
|
+
ctx.error(error.message);
|
|
183
|
+
return error.exitCode;
|
|
184
|
+
}
|
|
185
|
+
ctx.error(`unexpected error: ${error?.stack ?? error}`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The error type of the installer.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An error with a message for the user. The installer prints the message
|
|
5
|
+
* without a stack and exits with `exitCode` (1, or 2 for a usage error).
|
|
6
|
+
*/
|
|
7
|
+
export class InstallerError extends Error {
|
|
8
|
+
constructor(message, { exitCode = 1 } = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'InstallerError';
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A usage error: a wrong command line. Exit code 2. */
|
|
16
|
+
export function usageError(message) {
|
|
17
|
+
return new InstallerError(`${message}. Run "npx pandite --help" for the options.`, { exitCode: 2 });
|
|
18
|
+
}
|
package/lib/github.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The Pandite releases on GitHub: the latest release, a named release, the
|
|
2
|
+
// list of versions, and the SHA256SUMS.txt format. Anonymous requests only.
|
|
3
|
+
|
|
4
|
+
import { InstallerError } from './errors.js';
|
|
5
|
+
import { getText } from './net.js';
|
|
6
|
+
import { RELEASES_URL, REPO } from './platform.js';
|
|
7
|
+
import { compareVersions, parseVersion } from './version.js';
|
|
8
|
+
|
|
9
|
+
export const API_URL = `https://api.github.com/repos/${REPO}`;
|
|
10
|
+
const API_HEADERS = { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' };
|
|
11
|
+
const MAX_PAGES = 10;
|
|
12
|
+
|
|
13
|
+
function retryText(headers, now) {
|
|
14
|
+
const after = Number.parseInt(headers.get('retry-after') ?? '', 10);
|
|
15
|
+
const reset = Number.parseInt(headers.get('x-ratelimit-reset') ?? '', 10);
|
|
16
|
+
let seconds = null;
|
|
17
|
+
if (after >= 0) seconds = after;
|
|
18
|
+
else if (reset > 0) seconds = reset - Math.floor(now() / 1000);
|
|
19
|
+
if (seconds === null) return 'later';
|
|
20
|
+
const minutes = Math.max(1, Math.ceil(seconds / 60));
|
|
21
|
+
return `in ${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** GET an API address: { json, headers }, or null for HTTP 404. */
|
|
25
|
+
async function api(url, net) {
|
|
26
|
+
const got = await getText(url, net, API_HEADERS);
|
|
27
|
+
if (got.status === 200) {
|
|
28
|
+
try {
|
|
29
|
+
return { json: JSON.parse(got.text), headers: got.headers };
|
|
30
|
+
} catch {
|
|
31
|
+
throw new InstallerError(`GitHub sent an answer that is not JSON for ${url}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (got.status === 404) return null;
|
|
35
|
+
const limited = got.headers.get('x-ratelimit-remaining') === '0' || got.headers.has('retry-after');
|
|
36
|
+
if ((got.status === 403 || got.status === 429) && limited) {
|
|
37
|
+
throw new InstallerError(
|
|
38
|
+
'the GitHub API rate limit for this network is used up (60 requests per hour without sign-in). ' +
|
|
39
|
+
`Try again ${retryText(got.headers, net.now ?? Date.now)}.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
throw new InstallerError(`GitHub answered HTTP ${got.status} for ${url}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The version of a tag "v<version>", or null for another tag. */
|
|
46
|
+
export function tagVersion(tag) {
|
|
47
|
+
const text = String(tag ?? '');
|
|
48
|
+
if (!text.startsWith('v')) return null;
|
|
49
|
+
return parseVersion(text.slice(1)) ? text.slice(1) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toRelease(json, latest) {
|
|
53
|
+
const version = tagVersion(json?.tag_name);
|
|
54
|
+
if (!version) throw new InstallerError(`the release tag "${json?.tag_name}" is not v<version>`);
|
|
55
|
+
const assets = (Array.isArray(json.assets) ? json.assets : []).map((a) => ({
|
|
56
|
+
name: String(a?.name ?? ''),
|
|
57
|
+
url: String(a?.browser_download_url ?? ''),
|
|
58
|
+
size: Number(a?.size) || 0,
|
|
59
|
+
}));
|
|
60
|
+
return { tag: json.tag_name, version, prerelease: Boolean(json.prerelease), latest, assets };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The release of `version` (without "v"), or the latest release when
|
|
65
|
+
* `version` is null: { tag, version, prerelease, latest, assets }.
|
|
66
|
+
*/
|
|
67
|
+
export async function getRelease(version, net) {
|
|
68
|
+
const url = version ? `${API_URL}/releases/tags/v${version}` : `${API_URL}/releases/latest`;
|
|
69
|
+
const got = await api(url, net);
|
|
70
|
+
if (!got) {
|
|
71
|
+
if (version) {
|
|
72
|
+
throw new InstallerError(`no Pandite release v${version} found at ${RELEASES_URL}. Run "npx pandite --list" for the versions.`);
|
|
73
|
+
}
|
|
74
|
+
throw new InstallerError(`no Pandite release found at ${RELEASES_URL}`);
|
|
75
|
+
}
|
|
76
|
+
return toRelease(got.json, !version);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nextPage(link) {
|
|
80
|
+
const m = /<([^>]+)>;\s*rel="next"/.exec(link ?? '');
|
|
81
|
+
return m && m[1].startsWith('https://api.github.com/') ? m[1] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every published release version, newest first: [{ version, prerelease }]. */
|
|
85
|
+
export async function listReleases(net) {
|
|
86
|
+
const found = new Map();
|
|
87
|
+
let url = `${API_URL}/releases?per_page=100`;
|
|
88
|
+
for (let page = 0; url && page < MAX_PAGES; page += 1) {
|
|
89
|
+
const got = await api(url, net);
|
|
90
|
+
if (!got) break;
|
|
91
|
+
for (const r of Array.isArray(got.json) ? got.json : []) {
|
|
92
|
+
const version = tagVersion(r?.tag_name);
|
|
93
|
+
if (!version || r.draft) continue;
|
|
94
|
+
found.set(version, { version, prerelease: Boolean(r.prerelease) });
|
|
95
|
+
}
|
|
96
|
+
url = nextPage(got.headers.get('link'));
|
|
97
|
+
}
|
|
98
|
+
return [...found.values()].sort((a, b) => compareVersions(b.version, a.version));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse sha256sum output: Map of file name to lowercase SHA-256. */
|
|
102
|
+
export function parseSums(text) {
|
|
103
|
+
const sums = new Map();
|
|
104
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
105
|
+
const m = /^([0-9a-fA-F]{64}) [ *](.+?)\s*$/.exec(line);
|
|
106
|
+
if (m) sums.set(m[2], m[1].toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
return sums;
|
|
109
|
+
}
|