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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Farhad
|
|
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
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# bookmarks-but-better
|
|
2
|
+
|
|
3
|
+
Installs and looks after the [Bookmarks But Better](https://bookmarks.farhadeidi.com)
|
|
4
|
+
daemon on your machine.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx bookmarks-but-better@latest
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
That is the whole first run. It downloads the official installer for your
|
|
11
|
+
platform from the project's GitHub Release, verifies it against its published
|
|
12
|
+
SHA-256, installs the daemon into a user-local directory (no `sudo`, no
|
|
13
|
+
administrator prompt), asks one question — where your bookmarks should live —
|
|
14
|
+
and installs and starts the background service. Run it again later and it is a
|
|
15
|
+
menu: the status, then whatever can be done about it — each problem's fix
|
|
16
|
+
first, then add or remove a vault, update, uninstall.
|
|
17
|
+
|
|
18
|
+
Needs Node.js 20.12 or newer. The daemon it installs does not.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
Every command asks for what it was not given, so none of the arguments below
|
|
23
|
+
has to be typed; `--yes` answers every question with its default, for scripts.
|
|
24
|
+
|
|
25
|
+
| Command | What it does |
|
|
26
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
27
|
+
| _(none)_ | Installs when nothing is installed; otherwise the status and a menu of what to do. |
|
|
28
|
+
| `status` | What is installed, configured, running and connected, and the one command that fixes anything that is not. |
|
|
29
|
+
| `install` | Install or update the daemon, configure the first vault, install and start the service. Updates keep every vault. |
|
|
30
|
+
| `uninstall` | Stop and remove the service and the daemon. Vaults are never touched; the configuration is kept unless you say otherwise. |
|
|
31
|
+
| `vault list` | The configured vaults and what is true of each. |
|
|
32
|
+
| `vault add [<id> <path>]` | Configure another vault and restart the service so it hosts it. |
|
|
33
|
+
| `vault remove [<id>]` | Drop a vault from the configuration (the directory stays) and restart the service. |
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npx bookmarks-but-better@latest status
|
|
37
|
+
npx bookmarks-but-better@latest vault add work ~/Work/bookmarks
|
|
38
|
+
npx bookmarks-but-better@latest uninstall --purge-config
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
| Option | Applies to | What it does |
|
|
44
|
+
| --------------------- | ----------- | ----------------------------------------------------------------------- |
|
|
45
|
+
| `-y`, `--yes` | everything | Never ask; take every default. For scripts. |
|
|
46
|
+
| `--json` | status, vault list | Machine-readable output. |
|
|
47
|
+
| `--vault <dir>` | install | Where the first vault lives. Asked when left out; `~/Bookmarks` with `--yes`. |
|
|
48
|
+
| `--version <tag>` | install | Exactly this daemon release, e.g. `v4.2.0-beta.1`, instead of the one this tool was published for. |
|
|
49
|
+
| `--install-dir <dir>` | install | Where daemon versions are unpacked. |
|
|
50
|
+
| `--bin-dir <dir>` | install | Where the `bookmarks-but-better` symlink goes. macOS and Linux only. |
|
|
51
|
+
| `--purge-config` | uninstall | Also remove the configuration file. |
|
|
52
|
+
|
|
53
|
+
An option this platform has no equivalent for is refused before anything is
|
|
54
|
+
downloaded, rather than silently dropped.
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
This package ships **no binaries** and reads **no bookmarks**. It does two
|
|
59
|
+
things: run the daemon binary's own non-interactive commands and read their
|
|
60
|
+
`--json` answers, and run the official `install.sh` or `install.ps1` from the
|
|
61
|
+
GitHub Release. The questions live here, drawn with
|
|
62
|
+
[`@clack/prompts`](https://github.com/bombshell-dev/clack); the daemon asks
|
|
63
|
+
none.
|
|
64
|
+
|
|
65
|
+
By default it installs the daemon release it was **published for** (named in
|
|
66
|
+
its `package.json`), so the two never drift apart on one machine; `--version`
|
|
67
|
+
is the explicit way to choose otherwise. The tool's own version moves
|
|
68
|
+
independently, so a fix here does not wait for a daemon release.
|
|
69
|
+
|
|
70
|
+
The install is **persistent**: the daemon lives on afterwards in a user-local
|
|
71
|
+
directory, on your `PATH`, run by a login service (a `LaunchAgent`, a systemd
|
|
72
|
+
user unit, or a Scheduled Task). `npx` is only how this tool got to your
|
|
73
|
+
machine.
|
|
74
|
+
|
|
75
|
+
## Where things end up
|
|
76
|
+
|
|
77
|
+
| | macOS / Linux | Windows |
|
|
78
|
+
| ---------- | ----------------------------------------------------- | --------------------------------------- |
|
|
79
|
+
| Versions | `~/.local/share/bookmarks-but-better` | `%LOCALAPPDATA%\bookmarks-but-better` |
|
|
80
|
+
| On `PATH` | `~/.local/bin/bookmarks-but-better` | the install root's `current` directory |
|
|
81
|
+
| Configured | `~/.config/bookmarks-but-better/config.toml` | `%USERPROFILE%\.config\bookmarks-but-better\config.toml` |
|
|
82
|
+
|
|
83
|
+
Your vaults are directories of Markdown files that this tool never writes into
|
|
84
|
+
beyond the one root metadata file that makes a directory a vault, and never
|
|
85
|
+
deletes.
|
|
86
|
+
|
|
87
|
+
## Not using npm?
|
|
88
|
+
|
|
89
|
+
You do not need Node.js for any of this — it is one way in, not the way in:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
# macOS / Linux
|
|
93
|
+
curl -fsSL https://github.com/farhadeidi/bookmarks-but-better/releases/latest/download/install.sh | bash -s -- --vault ~/Bookmarks
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
# Windows
|
|
98
|
+
& ([scriptblock]::Create((irm https://github.com/farhadeidi/bookmarks-but-better/releases/latest/download/install.ps1))) -Vault "$env:USERPROFILE\Bookmarks"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [docs/DAEMON.md](https://github.com/farhadeidi/bookmarks-but-better/blob/main/docs/DAEMON.md).
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `npx bookmarks-but-better`: the Daemon Manager (ADR-0006).
|
|
3
|
+
//
|
|
4
|
+
// Six verbs — `status`, `install`, `uninstall`, `vault list|add|remove` — and
|
|
5
|
+
// nothing that reads or changes a bookmark. Run with no command it is a menu:
|
|
6
|
+
// it installs when nothing is installed, and otherwise shows the status and
|
|
7
|
+
// offers what can be done about it. Every command asks for what it was not
|
|
8
|
+
// given, and `--yes` answers every question with its default.
|
|
9
|
+
//
|
|
10
|
+
// Everything it does is one of two things: run the daemon binary's own
|
|
11
|
+
// non-interactive commands and read their `--json` answers, or run the
|
|
12
|
+
// official installer for this platform, fetched from the GitHub Release and
|
|
13
|
+
// verified against its published SHA-256. The questions live here; the daemon
|
|
14
|
+
// asks none.
|
|
15
|
+
//
|
|
16
|
+
// Every decision is in ../lib — cli, release, layout and status are pure and
|
|
17
|
+
// tested; daemon and prompt are the two modules that touch the machine and
|
|
18
|
+
// the terminal. This file is the flow between them.
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import { homedir, tmpdir } from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import process from "node:process";
|
|
26
|
+
|
|
27
|
+
import * as p from "@clack/prompts";
|
|
28
|
+
|
|
29
|
+
import { USAGE, installerFlags, parseArgs } from "../lib/cli.mjs";
|
|
30
|
+
import * as daemon from "../lib/daemon.mjs";
|
|
31
|
+
import { configPath, contractHome, expandHome, installLayout } from "../lib/layout.mjs";
|
|
32
|
+
import { Cancelled, createPrompter } from "../lib/prompt.mjs";
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_GITHUB_BASE,
|
|
35
|
+
checksumAssetName,
|
|
36
|
+
commandFor,
|
|
37
|
+
installerAssetName,
|
|
38
|
+
parseChecksumSidecar,
|
|
39
|
+
releaseAssetUrl,
|
|
40
|
+
releaseTagFor,
|
|
41
|
+
} from "../lib/release.mjs";
|
|
42
|
+
import { assess, compareBase, render, toJson } from "../lib/status.mjs";
|
|
43
|
+
|
|
44
|
+
const GITHUB_BASE = process.env.BOOKMARKS_BUT_BETTER_INSTALL_GITHUB_BASE || DEFAULT_GITHUB_BASE;
|
|
45
|
+
const HOME = homedir();
|
|
46
|
+
// The file that makes a directory a vault; the daemon writes it on `init`.
|
|
47
|
+
const VAULT_MARKER = ".bookmarks-but-better-folder.md";
|
|
48
|
+
const VAULT_ID = /^[a-z0-9-]{1,64}$/;
|
|
49
|
+
|
|
50
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
51
|
+
const TOOL_VERSION = packageJson.version;
|
|
52
|
+
// The daemon release this tool was published for, and installs by default.
|
|
53
|
+
const DAEMON_VERSION = packageJson.daemon.version;
|
|
54
|
+
|
|
55
|
+
const out = (text) => process.stdout.write(`${text}\n`);
|
|
56
|
+
/** A path for display: `~/…` where it is under the home directory. */
|
|
57
|
+
const shortHome = (value) => contractHome(value, HOME);
|
|
58
|
+
const tail = (text, lines = 12) => text.trim().split("\n").slice(-lines).join("\n");
|
|
59
|
+
|
|
60
|
+
class Failure extends Error {}
|
|
61
|
+
function fail(message) {
|
|
62
|
+
throw new Failure(message);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireInstalled(layout) {
|
|
66
|
+
if (!existsSync(layout.binary)) {
|
|
67
|
+
fail("the daemon is not installed; run: npx bookmarks-but-better install");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function download(url) {
|
|
72
|
+
const response = await fetch(url, { redirect: "follow" });
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`${response.status} ${response.statusText} for ${url}`);
|
|
75
|
+
}
|
|
76
|
+
return Buffer.from(await response.arrayBuffer());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs one of the daemon binary's commands under a spinner and logs what it
|
|
81
|
+
* said. Returns whether it succeeded.
|
|
82
|
+
*/
|
|
83
|
+
async function step(title, binary, args) {
|
|
84
|
+
const spin = p.spinner();
|
|
85
|
+
spin.start(title);
|
|
86
|
+
const result = await daemon.runQuiet(binary, args);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
spin.stop(title, 1);
|
|
89
|
+
p.log.error(tail(`${result.stdout}\n${result.stderr}`));
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
spin.stop(title);
|
|
93
|
+
const said = result.stdout.trim();
|
|
94
|
+
if (said) p.log.message(said);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Fetches, verifies and runs this platform's installer with `flags`. */
|
|
99
|
+
async function runInstaller(flags) {
|
|
100
|
+
const tag = releaseTagFor(flags);
|
|
101
|
+
const assetName = installerAssetName(process.platform);
|
|
102
|
+
const installerUrl = releaseAssetUrl({ name: assetName, tag, base: GITHUB_BASE });
|
|
103
|
+
const checksumUrl = releaseAssetUrl({ name: checksumAssetName(assetName), tag, base: GITHUB_BASE });
|
|
104
|
+
|
|
105
|
+
const scratch = await mkdtemp(path.join(tmpdir(), "bookmarks-but-better-"));
|
|
106
|
+
const scriptPath = path.join(scratch, assetName);
|
|
107
|
+
const spin = p.spinner();
|
|
108
|
+
try {
|
|
109
|
+
// Resolved before anything is downloaded: a flag with no equivalent on
|
|
110
|
+
// this platform is a refusal, not something to discover mid-install.
|
|
111
|
+
const invocation = commandFor({ platform: process.platform, scriptPath, forwarded: flags });
|
|
112
|
+
|
|
113
|
+
spin.start(`Fetching ${assetName} from the ${tag} release`);
|
|
114
|
+
const [installer, sidecar] = await Promise.all([download(installerUrl), download(checksumUrl)]);
|
|
115
|
+
const expected = parseChecksumSidecar(sidecar.toString("utf8"));
|
|
116
|
+
const actual = createHash("sha256").update(installer).digest("hex");
|
|
117
|
+
if (expected !== actual) {
|
|
118
|
+
spin.stop("The installer did not match its published checksum", 1);
|
|
119
|
+
fail(
|
|
120
|
+
`checksum verification failed for ${assetName} (expected ${expected}, got ${actual}); refusing to run a corrupted or tampered installer`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
await writeFile(scriptPath, installer, { mode: 0o700 });
|
|
124
|
+
spin.message("Installing the daemon and its background service");
|
|
125
|
+
|
|
126
|
+
const result = await daemon.runQuiet(invocation.command, invocation.args, {
|
|
127
|
+
env: { ...process.env, BOOKMARKS_BUT_BETTER_INSTALL_GITHUB_BASE: GITHUB_BASE },
|
|
128
|
+
});
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
spin.stop("The installer failed", 1);
|
|
131
|
+
p.log.error(tail(`${result.stdout}\n${result.stderr}`, 20));
|
|
132
|
+
fail(`the installer stopped with exit code ${result.code}`);
|
|
133
|
+
}
|
|
134
|
+
spin.stop("Daemon installed");
|
|
135
|
+
// The one thing worth repeating from the installer's own report.
|
|
136
|
+
const notes = result.stderr.split("\n").filter((line) => /^(note:| export PATH)/.test(line));
|
|
137
|
+
if (notes.length > 0) p.log.warn(notes.join("\n"));
|
|
138
|
+
} finally {
|
|
139
|
+
await rm(scratch, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Reads the machine into a report, waiting a moment for a daemon that just started. */
|
|
144
|
+
async function gather(layout, { settle = false } = {}) {
|
|
145
|
+
const report = await daemon.gather({ layout, toolVersion: TOOL_VERSION, daemonVersion: DAEMON_VERSION });
|
|
146
|
+
if (settle && !report.health && report.service?.state === "running") {
|
|
147
|
+
try {
|
|
148
|
+
report.health = await daemon.waitForHealth(report.origin);
|
|
149
|
+
report.healthError = null;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
report.healthError = error.message;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return report;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Prints the report; a bad state is an answer, not a failure. */
|
|
158
|
+
async function status({ layout, options }) {
|
|
159
|
+
const report = await gather(layout);
|
|
160
|
+
if (options.json) {
|
|
161
|
+
out(JSON.stringify(toJson(report), null, 2));
|
|
162
|
+
} else {
|
|
163
|
+
out(render(report, { homedir: HOME }));
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Installs or updates. The one question — where the first vault lives — is
|
|
170
|
+
* asked only when nothing is configured yet, before the installer runs, so
|
|
171
|
+
* the installer itself can do the whole first run with `--vault` and never
|
|
172
|
+
* has to ask anything.
|
|
173
|
+
*/
|
|
174
|
+
async function install({ layout, options, prompter }) {
|
|
175
|
+
const installed = existsSync(layout.binary);
|
|
176
|
+
let needsVault = !installed;
|
|
177
|
+
if (installed) {
|
|
178
|
+
// A registry with nothing in it next to an installed service is a 4.0.0
|
|
179
|
+
// install: the installer records that service's vaults itself, so the
|
|
180
|
+
// question is only for a machine where nothing names a vault at all. An
|
|
181
|
+
// older binary answers neither question (null); the installer sorts it out.
|
|
182
|
+
const hasVaults = await daemon.registryHasVaults(layout.binary);
|
|
183
|
+
const hasService = await daemon.serviceIsInstalled(layout.binary);
|
|
184
|
+
needsVault = hasVaults === false && hasService === false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let vault = options.vault ? expandHome(options.vault, HOME) : null;
|
|
188
|
+
if (needsVault && !vault) {
|
|
189
|
+
p.log.info(
|
|
190
|
+
installed
|
|
191
|
+
? "The daemon is installed but no vault is configured yet."
|
|
192
|
+
: "The daemon is not installed yet. A vault is a folder of Markdown files; one is created if the folder is empty or missing.",
|
|
193
|
+
);
|
|
194
|
+
vault = expandHome(
|
|
195
|
+
await prompter.ask("Where should your bookmarks live?", path.join(HOME, "Bookmarks"), {
|
|
196
|
+
validate: (value) =>
|
|
197
|
+
value && !path.isAbsolute(expandHome(value, HOME)) ? "Use an absolute path, or ~/…" : undefined,
|
|
198
|
+
}),
|
|
199
|
+
HOME,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (vault) vault = path.resolve(vault);
|
|
203
|
+
|
|
204
|
+
const plan = [
|
|
205
|
+
installed
|
|
206
|
+
? `update the daemon to ${options.version || DAEMON_VERSION}`
|
|
207
|
+
: `install the daemon ${options.version || DAEMON_VERSION} under ${shortHome(layout.installRoot)}`,
|
|
208
|
+
vault ? `use ${shortHome(vault)} as the vault` : null,
|
|
209
|
+
"install and start the background service",
|
|
210
|
+
].filter(Boolean);
|
|
211
|
+
if (!(await prompter.confirm(`This will ${plan.join(", ")}. Continue?`, { fallback: true }))) {
|
|
212
|
+
throw new Cancelled();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await runInstaller(installerFlags({ options, daemonVersion: DAEMON_VERSION, vault }));
|
|
216
|
+
const report = await gather(layout, { settle: true });
|
|
217
|
+
p.note(render(report, { homedir: HOME }), "Status");
|
|
218
|
+
return assess(report).ok ? 0 : 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Removes the service and the daemon. Vault directories are never touched,
|
|
223
|
+
* and the configuration file is kept unless asked otherwise — it is one small
|
|
224
|
+
* file, and a later install picks it up again.
|
|
225
|
+
*/
|
|
226
|
+
async function uninstall({ layout, options, prompter }) {
|
|
227
|
+
const fallbackConfig = configPath({ platform: process.platform, env: process.env, homedir: HOME });
|
|
228
|
+
|
|
229
|
+
if (!existsSync(layout.binary)) {
|
|
230
|
+
p.log.info(`Nothing is installed under ${shortHome(layout.installRoot)}.`);
|
|
231
|
+
if (options.purgeConfig && existsSync(fallbackConfig)) {
|
|
232
|
+
await rm(fallbackConfig, { force: true });
|
|
233
|
+
p.log.success(`Removed ${shortHome(fallbackConfig)}`);
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { value: registry } = await daemon.readRegistry(layout.binary);
|
|
239
|
+
const config = registry?.configuration ?? fallbackConfig;
|
|
240
|
+
const vaults = registry?.vaults ?? [];
|
|
241
|
+
|
|
242
|
+
const go = await prompter.confirm(
|
|
243
|
+
"Remove the daemon and its background service? Your vaults stay where they are.",
|
|
244
|
+
{ fallback: true },
|
|
245
|
+
);
|
|
246
|
+
if (!go) throw new Cancelled();
|
|
247
|
+
|
|
248
|
+
await step("Removing the background service", layout.binary, ["service", "uninstall"]);
|
|
249
|
+
await rm(layout.installRoot, { recursive: true, force: true });
|
|
250
|
+
if (layout.binLink) await rm(layout.binLink, { force: true });
|
|
251
|
+
if (process.platform === "win32") await daemon.removeFromUserPath(layout.current);
|
|
252
|
+
p.log.success(`Removed ${shortHome(layout.installRoot)}${layout.binLink ? ` and ${shortHome(layout.binLink)}` : ""}`);
|
|
253
|
+
|
|
254
|
+
let purge = Boolean(options.purgeConfig);
|
|
255
|
+
if (!purge && existsSync(config)) {
|
|
256
|
+
purge = await prompter.confirm(`Also remove the configuration at ${shortHome(config)}?`, {
|
|
257
|
+
fallback: false,
|
|
258
|
+
whenYes: false,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (purge) {
|
|
262
|
+
await rm(config, { force: true });
|
|
263
|
+
p.log.success(`Removed ${shortHome(config)}`);
|
|
264
|
+
} else if (existsSync(config)) {
|
|
265
|
+
p.log.info(`Kept ${shortHome(config)}; a later install picks it up again.`);
|
|
266
|
+
}
|
|
267
|
+
for (const vault of vaults) {
|
|
268
|
+
p.log.info(`Untouched: ${vault.id} ${shortHome(vault.path)}`);
|
|
269
|
+
}
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Reinstalls the service from the registry — which restarts it — when one is installed. */
|
|
274
|
+
async function applyToService({ layout, prompter, why }) {
|
|
275
|
+
const installed = await daemon.serviceIsInstalled(layout.binary);
|
|
276
|
+
if (!installed) {
|
|
277
|
+
p.log.info("No background service is installed; `install` sets one up.");
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
const go = await prompter.confirm(`Restart the background service ${why}?`, { fallback: true });
|
|
281
|
+
if (!go) {
|
|
282
|
+
p.log.warn("The running daemon keeps its current vaults until the service is reinstalled.");
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const spin = p.spinner();
|
|
287
|
+
spin.start("Restarting the background service");
|
|
288
|
+
const result = await daemon.runQuiet(layout.binary, [
|
|
289
|
+
"service",
|
|
290
|
+
"install",
|
|
291
|
+
"--from-config",
|
|
292
|
+
"--ui-dir",
|
|
293
|
+
layout.uiDir,
|
|
294
|
+
]);
|
|
295
|
+
if (!result.ok) {
|
|
296
|
+
spin.stop("The service could not be reinstalled", 1);
|
|
297
|
+
p.log.error(tail(`${result.stdout}\n${result.stderr}`));
|
|
298
|
+
return result.code;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { value: registry } = await daemon.readRegistry(layout.binary);
|
|
302
|
+
const { value: service } = await daemon.readService(layout.binary);
|
|
303
|
+
const origin = daemon.originOf({ registry, service });
|
|
304
|
+
try {
|
|
305
|
+
const health = await daemon.waitForHealth(origin);
|
|
306
|
+
spin.stop(`Service restarted; hosting ${health.vaults.map((vault) => vault.id).join(", ")}`);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
spin.stop(`Service restarted, but it has not answered at ${origin} yet (${error.message})`, 2);
|
|
309
|
+
}
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function vaultList({ layout, options }) {
|
|
314
|
+
requireInstalled(layout);
|
|
315
|
+
return daemon.runVisible(layout.binary, ["vault", "list", ...(options.json ? ["--json"] : [])]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function vaultAdd({ layout, args, prompter }) {
|
|
319
|
+
requireInstalled(layout);
|
|
320
|
+
let [id, given] = args;
|
|
321
|
+
if (!id) {
|
|
322
|
+
id = await prompter.ask("An id for the vault (lowercase letters, digits, hyphens)", "", {
|
|
323
|
+
validate: (value) => (VAULT_ID.test(value) ? undefined : "1–64 lowercase letters, digits and hyphens"),
|
|
324
|
+
});
|
|
325
|
+
if (!VAULT_ID.test(id)) fail("vault add needs an id: 1–64 lowercase letters, digits and hyphens");
|
|
326
|
+
given = await prompter.ask("Where is (or should be) the folder?", path.join(HOME, "Bookmarks", id), {
|
|
327
|
+
validate: (value) =>
|
|
328
|
+
value && !path.isAbsolute(expandHome(value, HOME)) ? "Use an absolute path, or ~/…" : undefined,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const directory = path.resolve(expandHome(given, HOME));
|
|
332
|
+
|
|
333
|
+
// A directory that is not a vault yet is made one only on a yes: `--init`
|
|
334
|
+
// is what turns a typo into a vault in the wrong place, so it is never
|
|
335
|
+
// implied.
|
|
336
|
+
let init = false;
|
|
337
|
+
if (!existsSync(path.join(directory, VAULT_MARKER))) {
|
|
338
|
+
init = await prompter.confirm(`${shortHome(directory)} is not a vault yet. Create one there?`, {
|
|
339
|
+
fallback: true,
|
|
340
|
+
});
|
|
341
|
+
if (!init) throw new Cancelled();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const ok = await step(`Adding \`${id}\` at ${shortHome(directory)}`, layout.binary, [
|
|
345
|
+
"vault",
|
|
346
|
+
"add",
|
|
347
|
+
id,
|
|
348
|
+
directory,
|
|
349
|
+
...(init ? ["--init"] : []),
|
|
350
|
+
]);
|
|
351
|
+
if (!ok) return 1;
|
|
352
|
+
return applyToService({ layout, prompter, why: `so it hosts \`${id}\`` });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function vaultRemove({ layout, args, prompter }) {
|
|
356
|
+
requireInstalled(layout);
|
|
357
|
+
let [id] = args;
|
|
358
|
+
if (!id) {
|
|
359
|
+
const { value: registry } = await daemon.readRegistry(layout.binary);
|
|
360
|
+
const vaults = registry?.vaults ?? [];
|
|
361
|
+
if (vaults.length === 0) {
|
|
362
|
+
p.log.info("No vault is configured.");
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
id = await prompter.select(
|
|
366
|
+
"Which vault should leave the configuration? Its directory stays.",
|
|
367
|
+
vaults.map((vault) => ({ value: vault.id, label: vault.id, hint: shortHome(vault.path) })),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const go = await prompter.confirm(`Remove \`${id}\` from the configuration? Its directory stays.`, {
|
|
372
|
+
fallback: false,
|
|
373
|
+
whenYes: true,
|
|
374
|
+
});
|
|
375
|
+
if (!go) throw new Cancelled();
|
|
376
|
+
|
|
377
|
+
const ok = await step(`Removing \`${id}\` from the configuration`, layout.binary, ["vault", "remove", id]);
|
|
378
|
+
if (!ok) return 1;
|
|
379
|
+
|
|
380
|
+
if ((await daemon.registryHasVaults(layout.binary)) === false) {
|
|
381
|
+
if (await daemon.serviceIsInstalled(layout.binary)) {
|
|
382
|
+
// A service with nothing to serve would fail at every login; a
|
|
383
|
+
// definition still naming the removed vault would serve it anyway.
|
|
384
|
+
p.log.info("No vault is left to serve, so the background service is removed too; `vault add` brings it back.");
|
|
385
|
+
return (await step("Removing the background service", layout.binary, ["service", "uninstall"])) ? 0 : 1;
|
|
386
|
+
}
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
return applyToService({ layout, prompter, why: `so it stops hosting \`${id}\`` });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* No command: the menu. Nothing installed means the first run; otherwise the
|
|
394
|
+
* status, then whatever can be done about it — each problem's fix first.
|
|
395
|
+
*/
|
|
396
|
+
async function menu(context) {
|
|
397
|
+
const { layout, prompter } = context;
|
|
398
|
+
if (!existsSync(layout.binary)) {
|
|
399
|
+
return install(context);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const report = await gather(layout);
|
|
403
|
+
// A menu needs someone to choose from it: with --yes, or no terminal, the
|
|
404
|
+
// status is the whole answer.
|
|
405
|
+
if (!prompter.interactive) {
|
|
406
|
+
p.note(render(report, { homedir: HOME }), "Status");
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
p.note(render(report, { homedir: HOME }), "Status");
|
|
410
|
+
const { problems } = assess(report);
|
|
411
|
+
const vaults = report.registry?.vaults ?? [];
|
|
412
|
+
|
|
413
|
+
const options = [];
|
|
414
|
+
problems.forEach((problem, index) => {
|
|
415
|
+
options.push({ value: `fix:${index}`, label: `Fix: ${problem.summary}`, hint: problem.fix });
|
|
416
|
+
});
|
|
417
|
+
options.push({ value: "add", label: "Add a vault", hint: "and restart the service so it hosts it" });
|
|
418
|
+
if (vaults.length > 0) {
|
|
419
|
+
options.push({ value: "remove", label: "Remove a vault", hint: "its directory stays" });
|
|
420
|
+
}
|
|
421
|
+
options.push({
|
|
422
|
+
value: "install",
|
|
423
|
+
label:
|
|
424
|
+
compareBase(report.binary.version, DAEMON_VERSION) < 0
|
|
425
|
+
? `Update the daemon to ${DAEMON_VERSION}`
|
|
426
|
+
: "Reinstall the daemon and its service",
|
|
427
|
+
hint: "keeps every vault",
|
|
428
|
+
});
|
|
429
|
+
options.push({ value: "uninstall", label: "Uninstall the daemon", hint: "vaults stay" });
|
|
430
|
+
options.push({ value: "exit", label: "Nothing, exit" });
|
|
431
|
+
|
|
432
|
+
const action = await prompter.select("What do you want to do?", options);
|
|
433
|
+
if (action === "exit") return 0;
|
|
434
|
+
if (action === "add") return vaultAdd({ ...context, args: [] });
|
|
435
|
+
if (action === "remove") return vaultRemove({ ...context, args: [] });
|
|
436
|
+
if (action === "install") return install(context);
|
|
437
|
+
if (action === "uninstall") return uninstall(context);
|
|
438
|
+
|
|
439
|
+
const problem = problems[Number(action.slice("fix:".length))];
|
|
440
|
+
switch (problem.action.kind) {
|
|
441
|
+
case "install":
|
|
442
|
+
return install(context);
|
|
443
|
+
case "vault-remove":
|
|
444
|
+
return vaultRemove({ ...context, args: [problem.action.id] });
|
|
445
|
+
default:
|
|
446
|
+
p.log.info(problem.fix);
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function main() {
|
|
452
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
453
|
+
if (parsed.errors.length > 0) {
|
|
454
|
+
for (const error of parsed.errors) process.stderr.write(`error: ${error}\n`);
|
|
455
|
+
process.stderr.write(`\n${USAGE}\n`);
|
|
456
|
+
return 2;
|
|
457
|
+
}
|
|
458
|
+
if (parsed.help) {
|
|
459
|
+
out(USAGE);
|
|
460
|
+
return 0;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { options } = parsed;
|
|
464
|
+
const layout = installLayout({
|
|
465
|
+
platform: process.platform,
|
|
466
|
+
env: process.env,
|
|
467
|
+
homedir: HOME,
|
|
468
|
+
installDir: options.installDir ? path.resolve(expandHome(options.installDir, HOME)) : null,
|
|
469
|
+
binDir: options.binDir ? path.resolve(expandHome(options.binDir, HOME)) : null,
|
|
470
|
+
});
|
|
471
|
+
const prompter = createPrompter({ yes: Boolean(options.yes) });
|
|
472
|
+
const context = { layout, options, args: parsed.args, prompter };
|
|
473
|
+
|
|
474
|
+
// The plain, scriptable readings print without decoration.
|
|
475
|
+
const plain = parsed.command === "status" || (parsed.command === "vault" && parsed.subcommand === "list");
|
|
476
|
+
if (plain) {
|
|
477
|
+
try {
|
|
478
|
+
return parsed.command === "status" ? await status(context) : await vaultList(context);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
481
|
+
return 1;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
p.intro(`Bookmarks But Better ${TOOL_VERSION}`);
|
|
486
|
+
try {
|
|
487
|
+
let code;
|
|
488
|
+
switch (parsed.command) {
|
|
489
|
+
case null:
|
|
490
|
+
code = await menu(context);
|
|
491
|
+
break;
|
|
492
|
+
case "install":
|
|
493
|
+
code = await install(context);
|
|
494
|
+
break;
|
|
495
|
+
case "uninstall":
|
|
496
|
+
code = await uninstall(context);
|
|
497
|
+
break;
|
|
498
|
+
default:
|
|
499
|
+
code = parsed.subcommand === "add" ? await vaultAdd(context) : await vaultRemove(context);
|
|
500
|
+
}
|
|
501
|
+
p.outro(code === 0 ? "Done." : "Stopped; see above.");
|
|
502
|
+
return code;
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (error instanceof Cancelled) {
|
|
505
|
+
p.cancel("Nothing was changed.");
|
|
506
|
+
return 1;
|
|
507
|
+
}
|
|
508
|
+
p.log.error(error.message);
|
|
509
|
+
p.outro("Stopped.");
|
|
510
|
+
return 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
process.exitCode = await main();
|