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 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();