bookmarks-but-better 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 +105 -0
- package/bin/bookmarks-but-better.mjs +514 -0
- package/lib/cli.mjs +134 -0
- package/lib/daemon.mjs +158 -0
- package/lib/layout.mjs +86 -0
- package/lib/prompt.mjs +57 -0
- package/lib/release.mjs +112 -0
- package/lib/status.mjs +254 -0
- package/package.json +42 -0
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// What `npx bookmarks-but-better …` was asked to do, read out of `argv` with no
|
|
2
|
+
// I/O — which is what lets `test/cli.test.mjs` cover every command, option and
|
|
3
|
+
// mistake without spawning anything.
|
|
4
|
+
|
|
5
|
+
export const COMMANDS = ["status", "install", "uninstall", "vault"];
|
|
6
|
+
export const VAULT_SUBCOMMANDS = ["list", "add", "remove"];
|
|
7
|
+
|
|
8
|
+
export const USAGE = `Usage: npx bookmarks-but-better [command] [options]
|
|
9
|
+
|
|
10
|
+
Installs and looks after the Bookmarks But Better daemon on this machine. Run
|
|
11
|
+
with no command, it is a menu: it installs the daemon when none is installed,
|
|
12
|
+
and otherwise shows the status and offers what can be done about it. Every
|
|
13
|
+
command asks for what it was not given; --yes answers with the defaults.
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
status What is installed, configured, running and connected,
|
|
17
|
+
and the one command that fixes anything that is not.
|
|
18
|
+
install Install or update the daemon, configure the first
|
|
19
|
+
vault, and install and start the background service.
|
|
20
|
+
Safe to run again: an update keeps every vault.
|
|
21
|
+
uninstall Stop and remove the service and the daemon. Vaults are
|
|
22
|
+
never touched; the configuration is kept unless
|
|
23
|
+
--purge-config says otherwise.
|
|
24
|
+
vault list The configured vaults and what is true of each.
|
|
25
|
+
vault add [<id> <path>]
|
|
26
|
+
Configure another vault and restart the service.
|
|
27
|
+
vault remove [<id>] Drop a vault from the configuration (the directory
|
|
28
|
+
stays) and restart the service. Removing the last
|
|
29
|
+
one removes the service too, until a vault is added.
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
-y, --yes Never ask; take every default. For scripts.
|
|
33
|
+
--json Machine-readable output (status, vault list).
|
|
34
|
+
--vault <dir> install: where the first vault lives. Asked when left
|
|
35
|
+
out; ~/Bookmarks with --yes.
|
|
36
|
+
--version <tag> install: exactly this daemon release, e.g.
|
|
37
|
+
v4.2.0-beta.1, instead of the one this tool was
|
|
38
|
+
released for.
|
|
39
|
+
--install-dir <dir> install: where daemon versions are unpacked.
|
|
40
|
+
--bin-dir <dir> install: where the bookmarks-but-better symlink goes.
|
|
41
|
+
macOS and Linux only.
|
|
42
|
+
--purge-config uninstall: also remove the configuration file.
|
|
43
|
+
-h, --help Show this help.`;
|
|
44
|
+
|
|
45
|
+
const OPTIONS = new Map([
|
|
46
|
+
["-y", { key: "yes" }],
|
|
47
|
+
["--yes", { key: "yes" }],
|
|
48
|
+
["--json", { key: "json" }],
|
|
49
|
+
["--purge-config", { key: "purgeConfig" }],
|
|
50
|
+
["--version", { key: "version", takesValue: true }],
|
|
51
|
+
["--install-dir", { key: "installDir", takesValue: true }],
|
|
52
|
+
["--bin-dir", { key: "binDir", takesValue: true }],
|
|
53
|
+
["--vault", { key: "vault", takesValue: true }],
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export const SUPPORTED_OPTIONS = [...OPTIONS.keys()];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Splits `argv` into a command, its arguments and its options. Every mistake
|
|
60
|
+
* is collected rather than thrown, so a user who made two gets told about
|
|
61
|
+
* both; an unknown option or command is refused here rather than guessed at.
|
|
62
|
+
*/
|
|
63
|
+
export function parseArgs(argv) {
|
|
64
|
+
const options = {};
|
|
65
|
+
const positionals = [];
|
|
66
|
+
const errors = [];
|
|
67
|
+
let help = false;
|
|
68
|
+
|
|
69
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
70
|
+
const argument = argv[index];
|
|
71
|
+
if (argument === "-h" || argument === "--help") {
|
|
72
|
+
help = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!argument.startsWith("-")) {
|
|
76
|
+
positionals.push(argument);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const option = OPTIONS.get(argument);
|
|
80
|
+
if (!option) {
|
|
81
|
+
errors.push(`unrecognized option: ${argument}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!option.takesValue) {
|
|
85
|
+
options[option.key] = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const value = argv[index + 1];
|
|
89
|
+
if (value === undefined || value.startsWith("-")) {
|
|
90
|
+
errors.push(`${argument} needs an argument`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
index += 1;
|
|
94
|
+
options[option.key] = value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [command = null, ...rest] = positionals;
|
|
98
|
+
let subcommand = null;
|
|
99
|
+
let args = rest;
|
|
100
|
+
|
|
101
|
+
if (command !== null && !COMMANDS.includes(command)) {
|
|
102
|
+
errors.push(`unknown command: ${command}`);
|
|
103
|
+
} else if (command === "vault") {
|
|
104
|
+
subcommand = rest[0] ?? null;
|
|
105
|
+
args = rest.slice(1);
|
|
106
|
+
if (!VAULT_SUBCOMMANDS.includes(subcommand)) {
|
|
107
|
+
errors.push("vault needs one of: list, add <id> <path>, remove <id>");
|
|
108
|
+
} else if (subcommand === "add" && args.length !== 0 && args.length !== 2) {
|
|
109
|
+
errors.push("vault add takes an id and a path, or nothing and asks for both");
|
|
110
|
+
} else if (subcommand === "remove" && args.length > 1) {
|
|
111
|
+
errors.push("vault remove takes one id, or nothing and offers a choice");
|
|
112
|
+
} else if (subcommand === "list" && args.length !== 0) {
|
|
113
|
+
errors.push("vault list takes no arguments");
|
|
114
|
+
}
|
|
115
|
+
} else if (command !== null && rest.length > 0) {
|
|
116
|
+
errors.push(`${command} takes no arguments`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { command, subcommand, args, options, help, errors };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The flags `install` hands the installer script. Left to itself the tool
|
|
124
|
+
* installs `daemonVersion` — the daemon release it was published for, whose
|
|
125
|
+
* `--json` output it reads (package.json's `daemon.version`). `--version` is
|
|
126
|
+
* the explicit way to choose otherwise, for trying a prerelease.
|
|
127
|
+
*/
|
|
128
|
+
export function installerFlags({ options, daemonVersion, vault = null }) {
|
|
129
|
+
const flags = ["--version", options.version || `v${daemonVersion}`];
|
|
130
|
+
if (options.installDir) flags.push("--install-dir", options.installDir);
|
|
131
|
+
if (options.binDir) flags.push("--bin-dir", options.binDir);
|
|
132
|
+
if (vault) flags.push("--vault", vault);
|
|
133
|
+
return flags;
|
|
134
|
+
}
|
package/lib/daemon.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Everything that touches the machine: running the daemon binary's own
|
|
2
|
+
// commands, reading their `--json` answers, and asking a running daemon how it
|
|
3
|
+
// is. The decisions about what those answers mean live in `status.mjs`.
|
|
4
|
+
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
import { emptyReport, formatOrigin, parseVersionOutput } from "./status.mjs";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/** Runs `binary args…` and returns what it printed, never throwing. */
|
|
14
|
+
export async function runQuiet(binary, args, { env = process.env } = {}) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, stderr } = await execFileAsync(binary, args, {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
19
|
+
env,
|
|
20
|
+
});
|
|
21
|
+
return { ok: true, code: 0, stdout, stderr };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
code: typeof error.code === "number" ? error.code : 1,
|
|
26
|
+
stdout: error.stdout ?? "",
|
|
27
|
+
stderr: error.stderr ?? String(error.message ?? error),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Runs `command args…` with the terminal attached, resolving to its exit code. */
|
|
33
|
+
export function runVisible(command, args, { env = process.env } = {}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = spawn(command, args, { stdio: "inherit", env });
|
|
36
|
+
child.on("error", reject);
|
|
37
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Runs a `--json` command and parses its answer. */
|
|
42
|
+
async function readJson(binary, args) {
|
|
43
|
+
const result = await runQuiet(binary, args);
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
// The first line only: a binary too old to know the command answers with
|
|
46
|
+
// its whole usage text, and the one line that says so is enough.
|
|
47
|
+
const said = result.stderr.trim().split("\n")[0]?.replace(/^error:\s*/, "");
|
|
48
|
+
return { value: null, error: said || `exit code ${result.code}` };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return { value: JSON.parse(result.stdout), error: null };
|
|
52
|
+
} catch {
|
|
53
|
+
return { value: null, error: `unreadable output from \`${args.join(" ")}\`` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readVersion(binary) {
|
|
58
|
+
const result = await runQuiet(binary, ["--version"]);
|
|
59
|
+
return result.ok ? parseVersionOutput(result.stdout) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readRegistry(binary) {
|
|
63
|
+
return readJson(binary, ["vault", "list", "--json"]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readService(binary) {
|
|
67
|
+
return readJson(binary, ["service", "status", "--json"]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `GET /api/v1/health`, with a short timeout: loopback answers at once or not at all. */
|
|
71
|
+
async function fetchHealth(origin, { timeoutMs = 2000 } = {}) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(`${origin}/api/v1/health`, { signal: controller.signal });
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
78
|
+
}
|
|
79
|
+
return await response.json();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(error.name === "AbortError" ? "timed out" : error.cause?.code ?? error.message);
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
|
+
|
|
89
|
+
/** Health, retried while a just-started daemon opens its vaults. */
|
|
90
|
+
export async function waitForHealth(origin, { attempts = 40, delayMs = 250 } = {}) {
|
|
91
|
+
let last = null;
|
|
92
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
93
|
+
try {
|
|
94
|
+
return await fetchHealth(origin);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
last = error;
|
|
97
|
+
await sleep(delayMs);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw last ?? new Error("no answer");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The daemon's address, from the configuration, else the service, else the defaults. */
|
|
104
|
+
export function originOf({ registry, service }) {
|
|
105
|
+
return formatOrigin(registry?.bind ?? undefined, registry?.port ?? service?.port ?? undefined);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reads everything `status` reports into one shape. */
|
|
109
|
+
export async function gather({ layout, toolVersion, daemonVersion }) {
|
|
110
|
+
const report = emptyReport({ toolVersion, daemonVersion, binaryPath: layout.binary });
|
|
111
|
+
if (!existsSync(layout.binary)) return report;
|
|
112
|
+
|
|
113
|
+
report.binary.installed = true;
|
|
114
|
+
report.binary.version = await readVersion(layout.binary);
|
|
115
|
+
|
|
116
|
+
const registry = await readRegistry(layout.binary);
|
|
117
|
+
report.registry = registry.value;
|
|
118
|
+
report.registryError = registry.error;
|
|
119
|
+
|
|
120
|
+
const service = await readService(layout.binary);
|
|
121
|
+
report.service = service.value;
|
|
122
|
+
report.serviceError = service.error;
|
|
123
|
+
|
|
124
|
+
report.origin = originOf({ registry: report.registry, service: report.service });
|
|
125
|
+
try {
|
|
126
|
+
report.health = await fetchHealth(report.origin);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
report.healthError = error.message;
|
|
129
|
+
}
|
|
130
|
+
return report;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether the registry lists any vault; `null` when it could not be read. */
|
|
134
|
+
export async function registryHasVaults(binary) {
|
|
135
|
+
const { value } = await readRegistry(binary);
|
|
136
|
+
return value ? value.vaults.length > 0 : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether a service definition is installed; `null` when it could not be asked. */
|
|
140
|
+
export async function serviceIsInstalled(binary) {
|
|
141
|
+
const { value } = await readService(binary);
|
|
142
|
+
return value ? value.state !== "not-installed" : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Takes `entry` off the user's PATH on Windows, where install.ps1 put it. A
|
|
147
|
+
* PowerShell one-liner because the user PATH lives in the registry, and
|
|
148
|
+
* `[Environment]::SetEnvironmentVariable` is the supported way to write it.
|
|
149
|
+
*/
|
|
150
|
+
export async function removeFromUserPath(entry) {
|
|
151
|
+
const script = [
|
|
152
|
+
"$entries = [Environment]::GetEnvironmentVariable('Path', 'User') -split ';'",
|
|
153
|
+
`$kept = $entries | Where-Object { $_ -and $_ -ne '${entry.replace(/'/g, "''")}' }`,
|
|
154
|
+
"[Environment]::SetEnvironmentVariable('Path', ($kept -join ';'), 'User')",
|
|
155
|
+
].join("; ");
|
|
156
|
+
const result = await runQuiet("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]);
|
|
157
|
+
return result.ok;
|
|
158
|
+
}
|
package/lib/layout.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Where the installers put things, computed the same way they compute it, so
|
|
2
|
+
// this tool finds the daemon the installer left behind and nothing else.
|
|
3
|
+
// String answers only: nothing here touches the filesystem.
|
|
4
|
+
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The install layout for `platform`. `installDir`/`binDir` are the explicit
|
|
9
|
+
* `--install-dir`/`--bin-dir` the user gave, which beat the environment,
|
|
10
|
+
* which beats the installer's defaults — the same precedence install.sh
|
|
11
|
+
* applies.
|
|
12
|
+
*/
|
|
13
|
+
export function installLayout({
|
|
14
|
+
platform,
|
|
15
|
+
env = {},
|
|
16
|
+
homedir,
|
|
17
|
+
installDir = null,
|
|
18
|
+
binDir = null,
|
|
19
|
+
}) {
|
|
20
|
+
if (platform === "win32") {
|
|
21
|
+
const p = path.win32;
|
|
22
|
+
const root =
|
|
23
|
+
installDir ||
|
|
24
|
+
env.BOOKMARKS_BUT_BETTER_INSTALL_ROOT ||
|
|
25
|
+
p.join(env.LOCALAPPDATA || p.join(homedir, "AppData", "Local"), "bookmarks-but-better");
|
|
26
|
+
const current = p.join(root, "current");
|
|
27
|
+
return {
|
|
28
|
+
platform,
|
|
29
|
+
installRoot: root,
|
|
30
|
+
current,
|
|
31
|
+
binary: p.join(current, "bookmarks-but-better.exe"),
|
|
32
|
+
uiDir: p.join(current, "ui"),
|
|
33
|
+
// install.ps1 puts `current` on the user's PATH instead of a symlink.
|
|
34
|
+
binDir: null,
|
|
35
|
+
binLink: null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const p = path.posix;
|
|
40
|
+
const root =
|
|
41
|
+
installDir ||
|
|
42
|
+
env.BOOKMARKS_BUT_BETTER_INSTALL_ROOT ||
|
|
43
|
+
p.join(homedir, ".local", "share", "bookmarks-but-better");
|
|
44
|
+
const links = binDir || env.BOOKMARKS_BUT_BETTER_BIN_DIR || p.join(homedir, ".local", "bin");
|
|
45
|
+
const current = p.join(root, "current");
|
|
46
|
+
return {
|
|
47
|
+
platform,
|
|
48
|
+
installRoot: root,
|
|
49
|
+
current,
|
|
50
|
+
binary: p.join(current, "bookmarks-but-better"),
|
|
51
|
+
uiDir: p.join(current, "ui"),
|
|
52
|
+
binDir: links,
|
|
53
|
+
binLink: p.join(links, "bookmarks-but-better"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where the daemon keeps its Vault Registry (ADR-0005), computed as the daemon
|
|
59
|
+
* computes it, for the one moment there is no daemon binary left to ask: the
|
|
60
|
+
* end of `uninstall`.
|
|
61
|
+
*/
|
|
62
|
+
export function configPath({ platform, env = {}, homedir }) {
|
|
63
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
64
|
+
const configHome = env.XDG_CONFIG_HOME || p.join(homedir, ".config");
|
|
65
|
+
return p.join(configHome, "bookmarks-but-better", "config.toml");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `~` and `~/…` as the shell would read them; anything else unchanged. */
|
|
69
|
+
export function expandHome(input, homedir) {
|
|
70
|
+
if (input === "~") return homedir;
|
|
71
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
72
|
+
return path.join(homedir, input.slice(2));
|
|
73
|
+
}
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The inverse, for display: a path under the home directory shown as `~/…`. */
|
|
78
|
+
export function contractHome(input, homedir) {
|
|
79
|
+
if (!homedir || !input) return input;
|
|
80
|
+
if (input === homedir) return "~";
|
|
81
|
+
const separator = input.includes("\\") && !input.includes("/") ? "\\" : "/";
|
|
82
|
+
if (input.startsWith(homedir + separator)) {
|
|
83
|
+
return `~${separator}${input.slice(homedir.length + 1)}`;
|
|
84
|
+
}
|
|
85
|
+
return input;
|
|
86
|
+
}
|
package/lib/prompt.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The only place this tool asks anything, drawn with @clack/prompts. `--yes`
|
|
2
|
+
// answers every question with its default, and a question with no terminal
|
|
3
|
+
// to ask on is an error rather than a silent default — the same rule the
|
|
4
|
+
// daemon's own commands follow.
|
|
5
|
+
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
|
|
8
|
+
/** The user pressed Ctrl-C or Escape on a question. */
|
|
9
|
+
export class Cancelled extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("cancelled");
|
|
12
|
+
this.name = "Cancelled";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPrompter({ yes = false, input = process.stdin } = {}) {
|
|
17
|
+
const interactive = Boolean(input.isTTY) && !yes;
|
|
18
|
+
|
|
19
|
+
function refuse(question, fallback) {
|
|
20
|
+
return new Error(
|
|
21
|
+
`${question} — there is no terminal to ask on; pass --yes to take the default (${fallback}), or say it on the command line`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unwrap(answer) {
|
|
26
|
+
if (p.isCancel(answer)) throw new Cancelled();
|
|
27
|
+
return answer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A free-text answer; empty takes `fallback`. */
|
|
31
|
+
async function ask(question, fallback, { validate } = {}) {
|
|
32
|
+
if (yes) return fallback;
|
|
33
|
+
if (!interactive) throw refuse(question, fallback);
|
|
34
|
+
const answer = unwrap(
|
|
35
|
+
await p.text({ message: question, placeholder: fallback, defaultValue: fallback, validate }),
|
|
36
|
+
);
|
|
37
|
+
return String(answer).trim() || fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A yes/no answer. `fallback` is what Enter means; `whenYes` is what `--yes`
|
|
42
|
+
* means, which for a destructive question is not always the default.
|
|
43
|
+
*/
|
|
44
|
+
async function confirm(question, { fallback = true, whenYes = fallback } = {}) {
|
|
45
|
+
if (yes) return whenYes;
|
|
46
|
+
if (!interactive) throw refuse(question, fallback ? "yes" : "no");
|
|
47
|
+
return unwrap(await p.confirm({ message: question, initialValue: fallback }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One of `options` (`{ value, label, hint? }`). A choice has no default, so it needs a terminal. */
|
|
51
|
+
async function select(question, options) {
|
|
52
|
+
if (!interactive) throw refuse(question, options.map((option) => option.value).join(" | "));
|
|
53
|
+
return unwrap(await p.select({ message: question, options }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { ask, confirm, select, interactive };
|
|
57
|
+
}
|
package/lib/release.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Where the installer comes from and how it is run: which script this
|
|
2
|
+
// platform needs, which GitHub Release URL it is fetched from, and how the
|
|
3
|
+
// flags `install` chose reach it. Pure functions of their inputs, so
|
|
4
|
+
// `test/release.test.mjs` covers them without a network or a spawned process.
|
|
5
|
+
|
|
6
|
+
export const REPO = "farhadeidi/bookmarks-but-better";
|
|
7
|
+
export const DEFAULT_GITHUB_BASE = "https://github.com";
|
|
8
|
+
|
|
9
|
+
/** The fixed-name installer each platform's release carries. */
|
|
10
|
+
export function installerAssetName(platform) {
|
|
11
|
+
return platform === "win32" ? "install.ps1" : "install.sh";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A GitHub Release asset URL, and never anything else — an installer fetched
|
|
16
|
+
* from a branch or from the website is not the one that was released and
|
|
17
|
+
* checksummed.
|
|
18
|
+
*
|
|
19
|
+
* `tag` of `null` means the latest release, which GitHub serves under its own
|
|
20
|
+
* `/releases/latest/download/` path.
|
|
21
|
+
*/
|
|
22
|
+
export function releaseAssetUrl({
|
|
23
|
+
name,
|
|
24
|
+
tag = null,
|
|
25
|
+
repo = REPO,
|
|
26
|
+
base = DEFAULT_GITHUB_BASE,
|
|
27
|
+
}) {
|
|
28
|
+
const root = `${base.replace(/\/+$/, "")}/${repo}/releases`;
|
|
29
|
+
return tag
|
|
30
|
+
? `${root}/download/${tag}/${name}`
|
|
31
|
+
: `${root}/latest/download/${name}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `<hash> <filename>` sidecar published next to every release asset. */
|
|
35
|
+
export function checksumAssetName(name) {
|
|
36
|
+
return `${name}.sha256`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The hash out of a `sha256sum`/`shasum -a 256` sidecar line. */
|
|
40
|
+
export function parseChecksumSidecar(text) {
|
|
41
|
+
const hash = String(text).trim().split(/\s+/)[0] ?? "";
|
|
42
|
+
if (!/^[0-9a-f]{64}$/i.test(hash)) {
|
|
43
|
+
throw new Error(`malformed .sha256 sidecar: ${JSON.stringify(text)}`);
|
|
44
|
+
}
|
|
45
|
+
return hash.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The release whose installer to fetch: the one named by `--version`, so the
|
|
50
|
+
* script that runs is the one published alongside the archive it installs.
|
|
51
|
+
* The manager always names one.
|
|
52
|
+
*/
|
|
53
|
+
export function releaseTagFor(flags) {
|
|
54
|
+
const index = flags.indexOf("--version");
|
|
55
|
+
if (index === -1) throw new Error("the installer flags name no --version");
|
|
56
|
+
const version = flags[index + 1];
|
|
57
|
+
return version.startsWith("v") ? version : `v${version}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every flag forwarded to the installers, and what install.ps1 calls it.
|
|
61
|
+
// `null` marks a flag install.ps1 has no equivalent for.
|
|
62
|
+
const FLAGS = new Map([
|
|
63
|
+
["--version", { takesValue: true, windows: "-Version" }],
|
|
64
|
+
["--install-dir", { takesValue: true, windows: "-InstallDir" }],
|
|
65
|
+
["--bin-dir", { takesValue: true, windows: null }],
|
|
66
|
+
["--vault", { takesValue: true, windows: "-Vault" }],
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How the downloaded installer is executed on this platform, including the
|
|
71
|
+
* translation of the forwarded flags into install.ps1's parameter names.
|
|
72
|
+
*
|
|
73
|
+
* Throws when a flag has no equivalent on the target platform — silently
|
|
74
|
+
* dropping it would install to somewhere other than where the user asked.
|
|
75
|
+
*/
|
|
76
|
+
export function commandFor({ platform, scriptPath, forwarded = [] }) {
|
|
77
|
+
if (platform !== "win32") {
|
|
78
|
+
// `bash`, never `sh`: install.sh uses `set -o pipefail`, a bash builtin
|
|
79
|
+
// option, and /bin/sh is dash on Debian and Ubuntu.
|
|
80
|
+
return { command: "bash", args: [scriptPath, ...forwarded] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const translated = [];
|
|
84
|
+
for (let index = 0; index < forwarded.length; index += 1) {
|
|
85
|
+
const flag = forwarded[index];
|
|
86
|
+
const option = FLAGS.get(flag);
|
|
87
|
+
if (!option) {
|
|
88
|
+
throw new Error(`cannot forward ${flag}`);
|
|
89
|
+
}
|
|
90
|
+
if (option.windows === null) {
|
|
91
|
+
throw new Error(`${flag} is not supported on Windows`);
|
|
92
|
+
}
|
|
93
|
+
translated.push(option.windows);
|
|
94
|
+
if (option.takesValue) {
|
|
95
|
+
index += 1;
|
|
96
|
+
translated.push(forwarded[index]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
command: "powershell",
|
|
102
|
+
args: [
|
|
103
|
+
"-NoProfile",
|
|
104
|
+
"-NonInteractive",
|
|
105
|
+
"-ExecutionPolicy",
|
|
106
|
+
"Bypass",
|
|
107
|
+
"-File",
|
|
108
|
+
scriptPath,
|
|
109
|
+
...translated,
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|