holos-installer 0.12.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/README.md +32 -0
- package/install.js +292 -0
- package/package.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# holos-installer
|
|
2
|
+
|
|
3
|
+
Installs [hcmd](https://github.com/xls/holos), a Total Commander alternative
|
|
4
|
+
for the terminal.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx holos-installer # install the latest release
|
|
8
|
+
npx holos-installer update # the same, and says so when already current
|
|
9
|
+
npx holos-installer --version # what is installed, and what is current
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
It downloads the release build for your platform, checks it against the
|
|
13
|
+
published `SHA256SUMS`, and installs to `~/.local/bin`. It never needs root.
|
|
14
|
+
|
|
15
|
+
**It always installs the latest release**, not the version of this package.
|
|
16
|
+
This package's version says when the installer itself last changed; what it
|
|
17
|
+
installs is whatever `xls/holos` has published, asked for at the moment you run
|
|
18
|
+
it.
|
|
19
|
+
|
|
20
|
+
| Variable | Meaning |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `HCMD_INSTALL_DIR` | where to put the binary (default `~/.local/bin`) |
|
|
23
|
+
| `HCMD_VERSION` | which release to fetch (default the latest) |
|
|
24
|
+
|
|
25
|
+
This package is the installer, not the program: it has no dependencies and
|
|
26
|
+
contains one Node script. If you would rather not run an installer at all,
|
|
27
|
+
download the tarball for your platform from
|
|
28
|
+
[Releases](https://github.com/xls/holos/releases), or use the shell equivalent:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
curl -fsSL https://raw.githubusercontent.com/xls/holos/main/install.sh | sh
|
|
32
|
+
```
|
package/install.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Install hcmd, from npm.
|
|
5
|
+
//
|
|
6
|
+
// npx holos-installer
|
|
7
|
+
//
|
|
8
|
+
// Downloads the release build for this platform, checks it against the
|
|
9
|
+
// published SHA256SUMS, and installs to ~/.local/bin. It never needs root and
|
|
10
|
+
// it writes nothing outside the install directory.
|
|
11
|
+
//
|
|
12
|
+
// Commands:
|
|
13
|
+
//
|
|
14
|
+
// npx holos-installer install the latest release
|
|
15
|
+
// npx holos-installer update the same, but says so when there is nothing
|
|
16
|
+
// to do rather than reinstalling in silence
|
|
17
|
+
// npx holos-installer --version what is installed, and what is current
|
|
18
|
+
//
|
|
19
|
+
// HCMD_INSTALL_DIR where to put the binary (default ~/.local/bin)
|
|
20
|
+
// HCMD_VERSION which release to fetch (default the latest)
|
|
21
|
+
//
|
|
22
|
+
// No dependencies on purpose. This is the first thing anyone runs, and a
|
|
23
|
+
// installer that pulls a tree of packages to install one binary is not a
|
|
24
|
+
// smaller ask than the binary. Everything here is Node's standard library
|
|
25
|
+
// plus `tar`, which both macOS and Linux have.
|
|
26
|
+
|
|
27
|
+
const fs = require("fs");
|
|
28
|
+
const os = require("os");
|
|
29
|
+
const path = require("path");
|
|
30
|
+
const https = require("https");
|
|
31
|
+
const crypto = require("crypto");
|
|
32
|
+
const { execFileSync } = require("child_process");
|
|
33
|
+
|
|
34
|
+
const REPO = "xls/holos";
|
|
35
|
+
const INSTALL_DIR =
|
|
36
|
+
process.env.HCMD_INSTALL_DIR || path.join(os.homedir(), ".local", "bin");
|
|
37
|
+
const SHARE_DIR =
|
|
38
|
+
process.env.HCMD_SHARE_DIR || path.join(os.homedir(), ".local", "share", "hcmd");
|
|
39
|
+
|
|
40
|
+
function say(msg) {
|
|
41
|
+
process.stdout.write(msg + "\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function die(msg) {
|
|
45
|
+
process.stderr.write("error: " + msg + "\n");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Which release build this machine wants.
|
|
50
|
+
///
|
|
51
|
+
/// The musl build runs anywhere; the glibc one starts faster and is smaller.
|
|
52
|
+
/// Node cannot tell which libc it is on without asking, so this asks `ldd`,
|
|
53
|
+
/// and treats "cannot tell" as musl, which is the one that works either way.
|
|
54
|
+
function target() {
|
|
55
|
+
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
|
|
56
|
+
if (!arch) die(`unsupported architecture: ${process.arch}`);
|
|
57
|
+
|
|
58
|
+
if (process.platform === "darwin") return `${arch}-apple-darwin`;
|
|
59
|
+
if (process.platform !== "linux") {
|
|
60
|
+
die(
|
|
61
|
+
`unsupported platform: ${process.platform} ` +
|
|
62
|
+
"(this installs Linux and macOS builds)"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let libc = "musl";
|
|
67
|
+
try {
|
|
68
|
+
const out = execFileSync("ldd", ["--version"], {
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
});
|
|
72
|
+
if (!/musl/i.test(out)) libc = "gnu";
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// `ldd --version` exits non-zero on musl and prints to stderr, and is
|
|
75
|
+
// absent entirely on some images. Both mean "do not assume glibc".
|
|
76
|
+
const text = String((err && err.stderr) || "");
|
|
77
|
+
if (text && !/musl/i.test(text)) libc = "gnu";
|
|
78
|
+
}
|
|
79
|
+
return `${arch}-unknown-linux-${libc}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// GET a URL into a Buffer, following redirects, which the release asset URLs
|
|
83
|
+
/// always issue.
|
|
84
|
+
function fetch(url, hops = 0) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
if (hops > 5) return reject(new Error("too many redirects"));
|
|
87
|
+
https
|
|
88
|
+
.get(url, { headers: { "User-Agent": "holos-installer" } }, (res) => {
|
|
89
|
+
if (
|
|
90
|
+
res.statusCode >= 300 &&
|
|
91
|
+
res.statusCode < 400 &&
|
|
92
|
+
res.headers.location
|
|
93
|
+
) {
|
|
94
|
+
res.resume();
|
|
95
|
+
return resolve(fetch(res.headers.location, hops + 1));
|
|
96
|
+
}
|
|
97
|
+
if (res.statusCode !== 200) {
|
|
98
|
+
res.resume();
|
|
99
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
100
|
+
}
|
|
101
|
+
const chunks = [];
|
|
102
|
+
res.on("data", (c) => chunks.push(c));
|
|
103
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
104
|
+
res.on("error", reject);
|
|
105
|
+
})
|
|
106
|
+
.on("error", reject);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// The latest published release, asked of GitHub.
|
|
111
|
+
///
|
|
112
|
+
/// This is what `install.sh` has always done, and what this did **not**: it
|
|
113
|
+
/// installed the version pinned in its own `package.json`, so `npx
|
|
114
|
+
/// holos-installer` kept installing whatever was current on the day the npm
|
|
115
|
+
/// package was last published. A pinned installer is a stale installer, and
|
|
116
|
+
/// nobody types `npx` to get last month's build.
|
|
117
|
+
async function latestVersion() {
|
|
118
|
+
const body = await fetch(
|
|
119
|
+
`https://api.github.com/repos/${REPO}/releases/latest`
|
|
120
|
+
);
|
|
121
|
+
const tag = JSON.parse(body.toString("utf8")).tag_name;
|
|
122
|
+
if (!tag) throw new Error("no tag_name in the latest release");
|
|
123
|
+
return String(tag).replace(/^v/, "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// The version to install: what was asked for, else the latest published.
|
|
127
|
+
async function version() {
|
|
128
|
+
if (process.env.HCMD_VERSION) return process.env.HCMD_VERSION;
|
|
129
|
+
try {
|
|
130
|
+
return await latestVersion();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
die(
|
|
133
|
+
`could not ask github.com for the latest release (${err.message}); ` +
|
|
134
|
+
"set HCMD_VERSION to install a particular one"
|
|
135
|
+
);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// What is installed already, or `null` when nothing is.
|
|
141
|
+
///
|
|
142
|
+
/// Runs the binary rather than remembering a number in a file: the file could
|
|
143
|
+
/// describe a binary that has since been replaced by hand, and the binary
|
|
144
|
+
/// cannot be wrong about itself.
|
|
145
|
+
function installedVersion() {
|
|
146
|
+
const bin = path.join(INSTALL_DIR, "hcmd");
|
|
147
|
+
if (!fs.existsSync(bin)) return null;
|
|
148
|
+
try {
|
|
149
|
+
const out = execFileSync(bin, ["--version"], {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
152
|
+
});
|
|
153
|
+
const found = out.match(/\b(\d+\.\d+\.\d+)\b/);
|
|
154
|
+
return found ? found[1] : null;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main() {
|
|
161
|
+
const argv = process.argv.slice(2);
|
|
162
|
+
const command = argv.find((a) => !a.startsWith("-"));
|
|
163
|
+
const plat = target();
|
|
164
|
+
|
|
165
|
+
// `--version` answers and stops. Both numbers, because the question behind
|
|
166
|
+
// it is always "am I behind".
|
|
167
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
168
|
+
const here = installedVersion();
|
|
169
|
+
say(here ? `installed: ${here}` : "installed: nothing in " + INSTALL_DIR);
|
|
170
|
+
try {
|
|
171
|
+
say(`latest: ${await latestVersion()}`);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
say(`latest: unknown (${err.message})`);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (command && command !== "install" && command !== "update") {
|
|
179
|
+
die(`unknown command: ${command} (there are "install" and "update")`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const ver = await version();
|
|
183
|
+
if (!ver) return;
|
|
184
|
+
|
|
185
|
+
// `update` is the same install, with one thing added: it says when there is
|
|
186
|
+
// nothing to do. Reinstalling an identical binary works and wastes a
|
|
187
|
+
// download, and silence about it reads as though something happened.
|
|
188
|
+
if (command === "update" && !process.env.HCMD_VERSION) {
|
|
189
|
+
const here = installedVersion();
|
|
190
|
+
if (here === ver) {
|
|
191
|
+
say(`hcmd ${here} is already the latest release; nothing to do`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (here) say(`hcmd ${here} installed, ${ver} is the latest`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const name = `hcmd-${ver}-${plat}`;
|
|
198
|
+
const base = `https://github.com/${REPO}/releases/download/v${ver}`;
|
|
199
|
+
say(`hcmd ${ver} for ${plat}`);
|
|
200
|
+
|
|
201
|
+
say("downloading...");
|
|
202
|
+
let archive;
|
|
203
|
+
try {
|
|
204
|
+
archive = await fetch(`${base}/${name}.tar.gz`);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
die(`no build published for ${plat} at v${ver}: ${err.message}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Verified against the release's own checksum file. A download that cannot
|
|
210
|
+
// be checked is reported rather than quietly trusted.
|
|
211
|
+
try {
|
|
212
|
+
const sums = (await fetch(`${base}/SHA256SUMS`)).toString("utf8");
|
|
213
|
+
const line = sums
|
|
214
|
+
.split("\n")
|
|
215
|
+
.find((l) => l.trim().endsWith(`${name}.tar.gz`));
|
|
216
|
+
if (!line) {
|
|
217
|
+
say(`warning: SHA256SUMS does not list ${name}.tar.gz`);
|
|
218
|
+
} else {
|
|
219
|
+
const want = line.trim().split(/\s+/)[0];
|
|
220
|
+
const got = crypto.createHash("sha256").update(archive).digest("hex");
|
|
221
|
+
if (want !== got) die(`checksum mismatch: expected ${want}, got ${got}`);
|
|
222
|
+
say("checksum ok");
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
say(`warning: could not verify the download: ${err.message}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hcmd-"));
|
|
229
|
+
try {
|
|
230
|
+
const tarball = path.join(tmp, "hcmd.tar.gz");
|
|
231
|
+
fs.writeFileSync(tarball, archive);
|
|
232
|
+
// `tar` rather than a bundled extractor: it is on every macOS and Linux
|
|
233
|
+
// this installs to, and it is one fewer thing to be wrong about.
|
|
234
|
+
execFileSync("tar", ["-xzf", tarball, "-C", tmp], { stdio: "inherit" });
|
|
235
|
+
|
|
236
|
+
const built = path.join(tmp, name, "hcmd");
|
|
237
|
+
if (!fs.existsSync(built)) die("no hcmd binary inside the archive");
|
|
238
|
+
|
|
239
|
+
fs.mkdirSync(INSTALL_DIR, { recursive: true });
|
|
240
|
+
// Written under a temporary name in the same directory and renamed, so a
|
|
241
|
+
// running hcmd is never half-overwritten.
|
|
242
|
+
const pending = path.join(INSTALL_DIR, ".hcmd.new");
|
|
243
|
+
fs.copyFileSync(built, pending);
|
|
244
|
+
fs.chmodSync(pending, 0o755);
|
|
245
|
+
fs.renameSync(pending, path.join(INSTALL_DIR, "hcmd"));
|
|
246
|
+
// The 21 themes are compiled into the binary, so every one of them works
|
|
247
|
+
// with no files at all. These are the editable copies: a theme is changed
|
|
248
|
+
// by putting a file of the same name in the config directory, and without
|
|
249
|
+
// a starting point there is nothing to copy. The tarball already carries
|
|
250
|
+
// them.
|
|
251
|
+
const themes = path.join(tmp, name, "themes");
|
|
252
|
+
if (fs.existsSync(themes)) {
|
|
253
|
+
try {
|
|
254
|
+
fs.mkdirSync(SHARE_DIR, { recursive: true });
|
|
255
|
+
fs.cpSync(themes, path.join(SHARE_DIR, "themes"), { recursive: true });
|
|
256
|
+
say(`themes in ${path.join(SHARE_DIR, "themes")}`);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
say(`warning: could not write the themes: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const examples = path.join(tmp, name, "examples");
|
|
262
|
+
if (fs.existsSync(examples)) {
|
|
263
|
+
try {
|
|
264
|
+
fs.mkdirSync(SHARE_DIR, { recursive: true });
|
|
265
|
+
fs.cpSync(examples, path.join(SHARE_DIR, "examples"), {
|
|
266
|
+
recursive: true,
|
|
267
|
+
});
|
|
268
|
+
say(`examples in ${path.join(SHARE_DIR, "examples")} (keymap, config)`);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
say(`warning: could not write the examples: ${err.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
say(`installed ${path.join(INSTALL_DIR, "hcmd")}`);
|
|
278
|
+
|
|
279
|
+
const onPath = (process.env.PATH || "")
|
|
280
|
+
.split(path.delimiter)
|
|
281
|
+
.includes(INSTALL_DIR);
|
|
282
|
+
if (!onPath) {
|
|
283
|
+
say("");
|
|
284
|
+
say(`${INSTALL_DIR} is not on your PATH. Add this to your shell profile:`);
|
|
285
|
+
say(` export PATH="$PATH:${INSTALL_DIR}"`);
|
|
286
|
+
}
|
|
287
|
+
say("");
|
|
288
|
+
say("Run hcmd to start. Configuration is written to");
|
|
289
|
+
say("~/.config/holoscommander/ the first time it runs.");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
main().catch((err) => die(err && err.message ? err.message : String(err)));
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "holos-installer",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "Install hcmd, a Total Commander alternative for the terminal",
|
|
5
|
+
"bin": { "holos-installer": "install.js" },
|
|
6
|
+
"files": ["install.js", "README.md"],
|
|
7
|
+
"keywords": ["file-manager", "tui", "terminal", "total-commander", "hcmd"],
|
|
8
|
+
"homepage": "https://github.com/xls/holos",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/xls/holos.git" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"engines": { "node": ">=18" },
|
|
12
|
+
"os": ["linux", "darwin"],
|
|
13
|
+
"cpu": ["x64", "arm64"]
|
|
14
|
+
}
|