sealkeep 0.5.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/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name to show a person, as opposed to the address to bind.
|
|
3
|
+
*
|
|
4
|
+
* `http://127.0.0.1:54142` is a correct URL and a terrible one: it looks like
|
|
5
|
+
* something has gone wrong, it cannot be read out loud, and the port changed
|
|
6
|
+
* every run because the server asked the kernel for whatever was free. The
|
|
7
|
+
* server still binds the loopback address — nothing is exposed by naming it —
|
|
8
|
+
* but the URL people see, bookmark and type is a name and a fixed port.
|
|
9
|
+
*
|
|
10
|
+
* `localhost` rather than something prettier because it is the only name that
|
|
11
|
+
* resolves everywhere without touching the system. `vault.localhost` is
|
|
12
|
+
* resolved internally by Chrome but not by the macOS resolver, so Safari would
|
|
13
|
+
* fail on it; `vault.line` needs an /etc/hosts line, which needs a password,
|
|
14
|
+
* which is not something this should take. `sealkeep ui hostname` prints that
|
|
15
|
+
* line for anyone who wants the nicer name and is happy to install it.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DISPLAY_HOST = "localhost";
|
|
18
|
+
/**
|
|
19
|
+
* A fixed default port, so the address is the same tomorrow as it was today.
|
|
20
|
+
*
|
|
21
|
+
* 7477 spells "VLIN"-ish on a keypad, sits well above the privileged range, and
|
|
22
|
+
* is not claimed by anything in the IANA registry. When it is busy the server
|
|
23
|
+
* still falls back to a free port rather than refusing to start: a working
|
|
24
|
+
* window on an odd port beats a correct error.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DEFAULT_UI_PORT = 7477;
|
|
27
|
+
/** The hostnames this service answers to, mirrored by ALLOWED_HOSTS in local-api.ts. */
|
|
28
|
+
export declare const UI_HOSTNAMES: readonly ["localhost", "127.0.0.1", "vault.line", "vault.localhost"];
|
|
29
|
+
/**
|
|
30
|
+
* Where a running server announces itself, so a second `sealkeep ui` finds it.
|
|
31
|
+
*
|
|
32
|
+
* It lives in the vault's own data dir, which is what makes it per-vault: two
|
|
33
|
+
* vaults on one machine have two data dirs and never see each other's server.
|
|
34
|
+
* It holds a port and a pid — no secret — but keeps the 0600 of everything
|
|
35
|
+
* else in there rather than being the one loose file.
|
|
36
|
+
*/
|
|
37
|
+
export declare const uiRecordPath: (dataDir: string) => string;
|
|
38
|
+
export type UiRecord = {
|
|
39
|
+
port: number;
|
|
40
|
+
pid: number;
|
|
41
|
+
startedAt: string;
|
|
42
|
+
};
|
|
43
|
+
/** Anything unreadable, truncated, or not describing a usable port reads as "no server", never as an error. */
|
|
44
|
+
export declare function readUiRecord(dataDir: string): Promise<UiRecord | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Written in place rather than through a temp file and a rename: a half-written
|
|
47
|
+
* record fails to parse, and failing to parse already means "start a fresh
|
|
48
|
+
* server", which is the safe answer. Atomicity would buy nothing.
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeUiRecord(dataDir: string, record: UiRecord): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Removes the record only when it still names this server's port. Two servers
|
|
53
|
+
* can exist at once (a second one starts whenever the first is unreachable),
|
|
54
|
+
* and the first to shut down must not delete the survivor's announcement.
|
|
55
|
+
*/
|
|
56
|
+
export declare function clearUiRecord(dataDir: string, port?: number): Promise<void>;
|
|
57
|
+
/** Where a person goes. Bound on loopback, named so it can be read and typed. */
|
|
58
|
+
export declare const uiOrigin: (port: number, host?: string) => string;
|
|
59
|
+
/** Where this process talks to itself. Always the address, never a name that has to resolve. */
|
|
60
|
+
export declare const uiProbeOrigin: (port: number) => string;
|
|
61
|
+
/**
|
|
62
|
+
* How the browser gets authenticated: the token rides in the URL fragment.
|
|
63
|
+
*
|
|
64
|
+
* A fragment is the only part of a URL a browser never transmits. It is not in
|
|
65
|
+
* the request line, so it cannot reach an access log, a proxy, or a Referer
|
|
66
|
+
* header on any link the page later follows — and `web/app.js` reads it once,
|
|
67
|
+
* moves it into sessionStorage, and history.replaceState()s it out of the
|
|
68
|
+
* address bar, so it does not survive in browser history either. A `?token=`
|
|
69
|
+
* query string would have failed every one of those.
|
|
70
|
+
*
|
|
71
|
+
* The alternative — print the token and have someone paste it into the gate —
|
|
72
|
+
* is the terminal step this whole command exists to remove, and it puts the
|
|
73
|
+
* secret in scrollback, in `script` logs, and in any pasted transcript.
|
|
74
|
+
*
|
|
75
|
+
* What this does cost: the URL is an argv of the opener process, so it is
|
|
76
|
+
* visible to `ps` for the few milliseconds that process lives. That is
|
|
77
|
+
* accepted rather than overlooked — the token grants nothing that a local
|
|
78
|
+
* process running as this user does not already have, since it can read
|
|
79
|
+
* <data-dir>/local-api-token directly. Scrollback is the leak that outlives
|
|
80
|
+
* the session; argv is not.
|
|
81
|
+
*/
|
|
82
|
+
export declare const uiUrl: (port: number, token: string) => string;
|
|
83
|
+
export type BrowserResult = {
|
|
84
|
+
opened: boolean;
|
|
85
|
+
/** The opener that ran, for a message that names what failed rather than "something went wrong". */
|
|
86
|
+
command: string | null;
|
|
87
|
+
problem?: string;
|
|
88
|
+
/** True when no browser was wanted (`--no-browser`), which is a choice, not a failure to warn about. */
|
|
89
|
+
declined?: boolean;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Which command opens a URL here.
|
|
93
|
+
*
|
|
94
|
+
* Three platforms, three answers, and one of them is not an executable:
|
|
95
|
+
* Windows `start` is a cmd.exe builtin, so it has to be invoked through cmd —
|
|
96
|
+
* spawning "start" directly fails with ENOENT. The empty "" that follows is
|
|
97
|
+
* `start`'s window-title argument, which it would otherwise take from a quoted
|
|
98
|
+
* URL and then open nothing.
|
|
99
|
+
*
|
|
100
|
+
* `BROWSER` is honoured first because it is the existing convention for
|
|
101
|
+
* exactly this (xdg-open reads it, as do most tools that open pages), and
|
|
102
|
+
* `BROWSER=none` is how a headless box or a CI job says "do not launch
|
|
103
|
+
* anything" — answered here with null, which callers degrade to printing the
|
|
104
|
+
* URL.
|
|
105
|
+
*/
|
|
106
|
+
export declare function openerFor(url: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): {
|
|
107
|
+
command: string;
|
|
108
|
+
args: string[];
|
|
109
|
+
} | null;
|
|
110
|
+
/**
|
|
111
|
+
* Launches a browser without ever waiting for it.
|
|
112
|
+
*
|
|
113
|
+
* The child is detached and unref'd because `xdg-open` can stay in the
|
|
114
|
+
* foreground for the browser's entire life on some desktops: waiting for it
|
|
115
|
+
* would hold "here is your URL, Ctrl-C to stop" hostage until the person
|
|
116
|
+
* closed their browser, and killing it on a timeout could take the browser
|
|
117
|
+
* with it. So only an *immediate* failure is worth listening for — a missing
|
|
118
|
+
* opener (ENOENT) or one that gives up straight away (xdg-open's exit 3, "no
|
|
119
|
+
* method available") — and after a short grace period the window is assumed to
|
|
120
|
+
* be on its way.
|
|
121
|
+
*
|
|
122
|
+
* Nothing here throws. A browser that will not open is a message to print, not
|
|
123
|
+
* a reason to fail a command that has already started a working server.
|
|
124
|
+
*/
|
|
125
|
+
export declare function openInBrowser(url: string, options?: {
|
|
126
|
+
env?: NodeJS.ProcessEnv;
|
|
127
|
+
platform?: NodeJS.Platform;
|
|
128
|
+
graceMs?: number;
|
|
129
|
+
}): Promise<BrowserResult>;
|
|
130
|
+
/**
|
|
131
|
+
* Is a Sealkeep local API still listening on `port`, and is it *this* vault's?
|
|
132
|
+
*
|
|
133
|
+
* Two questions, because a recorded port answers neither. A port is reused by
|
|
134
|
+
* whatever process grabs it next, so `/health` first establishes that the
|
|
135
|
+
* listener is one of ours at all. Then a `/v1/status` call carrying this
|
|
136
|
+
* vault's token establishes that it reads the same token file — that is, that
|
|
137
|
+
* it serves this data dir and not a second vault's. 401 is the only answer
|
|
138
|
+
* that means "not this vault": every other status got past the token check,
|
|
139
|
+
* including the 404 a data dir with no vault yet would return.
|
|
140
|
+
*/
|
|
141
|
+
export declare function servesThisVault(port: number, token: string, fetchImpl?: typeof fetch): Promise<boolean>;
|
|
142
|
+
export type UiHandle = {
|
|
143
|
+
port: number;
|
|
144
|
+
origin: string;
|
|
145
|
+
/**
|
|
146
|
+
* The address actually bound, which is the thing that decides who can reach
|
|
147
|
+
* this — as opposed to `origin`, which is only the name shown to a person.
|
|
148
|
+
* Exposed so "loopback only" can be asserted rather than inferred from how
|
|
149
|
+
* the URL happens to be spelled.
|
|
150
|
+
*/
|
|
151
|
+
address: string;
|
|
152
|
+
/** The full URL including the token fragment. Print it only when no browser could be opened. */
|
|
153
|
+
url: string;
|
|
154
|
+
/** True when an already-running server was reused; nothing new was started and close() is a no-op. */
|
|
155
|
+
reused: boolean;
|
|
156
|
+
/** Set when reused, so a caller can say who owns the server it just pointed at. */
|
|
157
|
+
running: UiRecord | null;
|
|
158
|
+
browser: BrowserResult;
|
|
159
|
+
close: () => Promise<void>;
|
|
160
|
+
};
|
|
161
|
+
export type UiOptions = {
|
|
162
|
+
/** 0 — the default — asks the OS for an ephemeral port, so two vaults never fight over one. */
|
|
163
|
+
port?: number;
|
|
164
|
+
/** False for a headless surface that serves the API but has no window to open. */
|
|
165
|
+
openBrowser?: boolean;
|
|
166
|
+
/** Injected by tests so a suite never launches a real browser. */
|
|
167
|
+
open?: (url: string) => Promise<BrowserResult>;
|
|
168
|
+
/** Injected by tests that need to control what the reuse probe sees. */
|
|
169
|
+
fetchImpl?: typeof fetch;
|
|
170
|
+
/**
|
|
171
|
+
* False for `sealkeep api`, whose port is a stable address other tools are
|
|
172
|
+
* pointed at — handing it back an ephemeral one because a window happened to
|
|
173
|
+
* be open would break the thing that makes it useful. It still announces
|
|
174
|
+
* itself, so a later `sealkeep ui` finds it rather than starting a second.
|
|
175
|
+
*/
|
|
176
|
+
reuse?: boolean;
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Starts the local UI, or points at the one already running for this vault.
|
|
180
|
+
*
|
|
181
|
+
* Reuse is not an optimisation: a second server would mint no second token but
|
|
182
|
+
* would leave two listeners and two records for one vault, and whichever one
|
|
183
|
+
* the person closed first would look like "the UI stopped working". Finding
|
|
184
|
+
* the running one and opening a browser at it is the behaviour that matches
|
|
185
|
+
* what someone typing the command twice actually meant.
|
|
186
|
+
*/
|
|
187
|
+
export declare function startUi(dataDir: string, options?: UiOptions): Promise<UiHandle>;
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createLocalApiServer, localApiToken } from "./local-api.js";
|
|
5
|
+
/**
|
|
6
|
+
* The GUI, reachable without typing a command.
|
|
7
|
+
*
|
|
8
|
+
* PRODUCT_ARCHITECTURE.md §5: the terminal is the free tier's interface, not
|
|
9
|
+
* the product's. `src/local-api.ts` already serves the settings surface and
|
|
10
|
+
* `web/` already draws it — what was missing was a way to get a browser
|
|
11
|
+
* pointed at it, authenticated, without a person copying a token by hand.
|
|
12
|
+
*
|
|
13
|
+
* This module is the plumbing for that and nothing else. It starts (or finds)
|
|
14
|
+
* the loopback server, builds the URL that carries the token, and launches a
|
|
15
|
+
* browser. Every line printed to a terminal lives in cli.ts, so this stays
|
|
16
|
+
* usable by any surface that is not a terminal — the native shell in §5 v2
|
|
17
|
+
* being the obvious one.
|
|
18
|
+
*/
|
|
19
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
20
|
+
/**
|
|
21
|
+
* The name to show a person, as opposed to the address to bind.
|
|
22
|
+
*
|
|
23
|
+
* `http://127.0.0.1:54142` is a correct URL and a terrible one: it looks like
|
|
24
|
+
* something has gone wrong, it cannot be read out loud, and the port changed
|
|
25
|
+
* every run because the server asked the kernel for whatever was free. The
|
|
26
|
+
* server still binds the loopback address — nothing is exposed by naming it —
|
|
27
|
+
* but the URL people see, bookmark and type is a name and a fixed port.
|
|
28
|
+
*
|
|
29
|
+
* `localhost` rather than something prettier because it is the only name that
|
|
30
|
+
* resolves everywhere without touching the system. `vault.localhost` is
|
|
31
|
+
* resolved internally by Chrome but not by the macOS resolver, so Safari would
|
|
32
|
+
* fail on it; `vault.line` needs an /etc/hosts line, which needs a password,
|
|
33
|
+
* which is not something this should take. `sealkeep ui hostname` prints that
|
|
34
|
+
* line for anyone who wants the nicer name and is happy to install it.
|
|
35
|
+
*/
|
|
36
|
+
export const DISPLAY_HOST = "localhost";
|
|
37
|
+
/**
|
|
38
|
+
* A fixed default port, so the address is the same tomorrow as it was today.
|
|
39
|
+
*
|
|
40
|
+
* 7477 spells "VLIN"-ish on a keypad, sits well above the privileged range, and
|
|
41
|
+
* is not claimed by anything in the IANA registry. When it is busy the server
|
|
42
|
+
* still falls back to a free port rather than refusing to start: a working
|
|
43
|
+
* window on an odd port beats a correct error.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_UI_PORT = 7477;
|
|
46
|
+
/** The hostnames this service answers to, mirrored by ALLOWED_HOSTS in local-api.ts. */
|
|
47
|
+
export const UI_HOSTNAMES = ["localhost", "127.0.0.1", "vault.line", "vault.localhost"];
|
|
48
|
+
/** Loopback round trips are sub-millisecond; anything slower is a listener that has stopped answering. */
|
|
49
|
+
const PROBE_TIMEOUT_MS = 1_500;
|
|
50
|
+
/** How long to wait for an opener to fail before assuming the window is on its way. See openInBrowser. */
|
|
51
|
+
const OPENER_GRACE_MS = 400;
|
|
52
|
+
/**
|
|
53
|
+
* Where a running server announces itself, so a second `sealkeep ui` finds it.
|
|
54
|
+
*
|
|
55
|
+
* It lives in the vault's own data dir, which is what makes it per-vault: two
|
|
56
|
+
* vaults on one machine have two data dirs and never see each other's server.
|
|
57
|
+
* It holds a port and a pid — no secret — but keeps the 0600 of everything
|
|
58
|
+
* else in there rather than being the one loose file.
|
|
59
|
+
*/
|
|
60
|
+
export const uiRecordPath = (dataDir) => join(dataDir, "ui-server.json");
|
|
61
|
+
/** Anything unreadable, truncated, or not describing a usable port reads as "no server", never as an error. */
|
|
62
|
+
export async function readUiRecord(dataDir) {
|
|
63
|
+
try {
|
|
64
|
+
const saved = JSON.parse(await readFile(uiRecordPath(dataDir), "utf8"));
|
|
65
|
+
const port = Number(saved?.port);
|
|
66
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
67
|
+
return null;
|
|
68
|
+
return { port, pid: Number(saved?.pid) || 0, startedAt: String(saved?.startedAt ?? "") };
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Written in place rather than through a temp file and a rename: a half-written
|
|
76
|
+
* record fails to parse, and failing to parse already means "start a fresh
|
|
77
|
+
* server", which is the safe answer. Atomicity would buy nothing.
|
|
78
|
+
*/
|
|
79
|
+
export async function writeUiRecord(dataDir, record) {
|
|
80
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
81
|
+
await writeFile(uiRecordPath(dataDir), JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Removes the record only when it still names this server's port. Two servers
|
|
85
|
+
* can exist at once (a second one starts whenever the first is unreachable),
|
|
86
|
+
* and the first to shut down must not delete the survivor's announcement.
|
|
87
|
+
*/
|
|
88
|
+
export async function clearUiRecord(dataDir, port) {
|
|
89
|
+
if (port !== undefined) {
|
|
90
|
+
const recorded = await readUiRecord(dataDir);
|
|
91
|
+
if (recorded && recorded.port !== port)
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await rm(uiRecordPath(dataDir), { force: true });
|
|
95
|
+
}
|
|
96
|
+
/** Where a person goes. Bound on loopback, named so it can be read and typed. */
|
|
97
|
+
export const uiOrigin = (port, host = DISPLAY_HOST) => `http://${host}:${port}`;
|
|
98
|
+
/** Where this process talks to itself. Always the address, never a name that has to resolve. */
|
|
99
|
+
export const uiProbeOrigin = (port) => `http://${LOOPBACK_HOST}:${port}`;
|
|
100
|
+
/**
|
|
101
|
+
* How the browser gets authenticated: the token rides in the URL fragment.
|
|
102
|
+
*
|
|
103
|
+
* A fragment is the only part of a URL a browser never transmits. It is not in
|
|
104
|
+
* the request line, so it cannot reach an access log, a proxy, or a Referer
|
|
105
|
+
* header on any link the page later follows — and `web/app.js` reads it once,
|
|
106
|
+
* moves it into sessionStorage, and history.replaceState()s it out of the
|
|
107
|
+
* address bar, so it does not survive in browser history either. A `?token=`
|
|
108
|
+
* query string would have failed every one of those.
|
|
109
|
+
*
|
|
110
|
+
* The alternative — print the token and have someone paste it into the gate —
|
|
111
|
+
* is the terminal step this whole command exists to remove, and it puts the
|
|
112
|
+
* secret in scrollback, in `script` logs, and in any pasted transcript.
|
|
113
|
+
*
|
|
114
|
+
* What this does cost: the URL is an argv of the opener process, so it is
|
|
115
|
+
* visible to `ps` for the few milliseconds that process lives. That is
|
|
116
|
+
* accepted rather than overlooked — the token grants nothing that a local
|
|
117
|
+
* process running as this user does not already have, since it can read
|
|
118
|
+
* <data-dir>/local-api-token directly. Scrollback is the leak that outlives
|
|
119
|
+
* the session; argv is not.
|
|
120
|
+
*/
|
|
121
|
+
export const uiUrl = (port, token) => `${uiOrigin(port)}/#token=${encodeURIComponent(token)}`;
|
|
122
|
+
/**
|
|
123
|
+
* Which command opens a URL here.
|
|
124
|
+
*
|
|
125
|
+
* Three platforms, three answers, and one of them is not an executable:
|
|
126
|
+
* Windows `start` is a cmd.exe builtin, so it has to be invoked through cmd —
|
|
127
|
+
* spawning "start" directly fails with ENOENT. The empty "" that follows is
|
|
128
|
+
* `start`'s window-title argument, which it would otherwise take from a quoted
|
|
129
|
+
* URL and then open nothing.
|
|
130
|
+
*
|
|
131
|
+
* `BROWSER` is honoured first because it is the existing convention for
|
|
132
|
+
* exactly this (xdg-open reads it, as do most tools that open pages), and
|
|
133
|
+
* `BROWSER=none` is how a headless box or a CI job says "do not launch
|
|
134
|
+
* anything" — answered here with null, which callers degrade to printing the
|
|
135
|
+
* URL.
|
|
136
|
+
*/
|
|
137
|
+
export function openerFor(url, env = process.env, platform = process.platform) {
|
|
138
|
+
const preference = env.BROWSER?.trim();
|
|
139
|
+
if (preference)
|
|
140
|
+
return preference.toLowerCase() === "none" ? null : { command: preference, args: [url] };
|
|
141
|
+
if (platform === "darwin")
|
|
142
|
+
return { command: "open", args: [url] };
|
|
143
|
+
if (platform === "win32")
|
|
144
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
145
|
+
return { command: "xdg-open", args: [url] };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Launches a browser without ever waiting for it.
|
|
149
|
+
*
|
|
150
|
+
* The child is detached and unref'd because `xdg-open` can stay in the
|
|
151
|
+
* foreground for the browser's entire life on some desktops: waiting for it
|
|
152
|
+
* would hold "here is your URL, Ctrl-C to stop" hostage until the person
|
|
153
|
+
* closed their browser, and killing it on a timeout could take the browser
|
|
154
|
+
* with it. So only an *immediate* failure is worth listening for — a missing
|
|
155
|
+
* opener (ENOENT) or one that gives up straight away (xdg-open's exit 3, "no
|
|
156
|
+
* method available") — and after a short grace period the window is assumed to
|
|
157
|
+
* be on its way.
|
|
158
|
+
*
|
|
159
|
+
* Nothing here throws. A browser that will not open is a message to print, not
|
|
160
|
+
* a reason to fail a command that has already started a working server.
|
|
161
|
+
*/
|
|
162
|
+
export async function openInBrowser(url, options = {}) {
|
|
163
|
+
const opener = openerFor(url, options.env ?? process.env, options.platform ?? process.platform);
|
|
164
|
+
if (!opener)
|
|
165
|
+
return { opened: false, command: null, problem: "BROWSER is set to none, so nothing was launched" };
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
let settled = false;
|
|
168
|
+
let timer;
|
|
169
|
+
const finish = (result) => {
|
|
170
|
+
if (settled)
|
|
171
|
+
return;
|
|
172
|
+
settled = true;
|
|
173
|
+
if (timer)
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve(result);
|
|
176
|
+
};
|
|
177
|
+
let child;
|
|
178
|
+
try {
|
|
179
|
+
child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
finish({ opened: false, command: opener.command, problem: error instanceof Error ? error.message : "could not start a browser" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
timer = setTimeout(() => finish({ opened: true, command: opener.command }), options.graceMs ?? OPENER_GRACE_MS);
|
|
186
|
+
child.once("error", (error) => finish({ opened: false, command: opener.command, problem: error.message }));
|
|
187
|
+
child.once("exit", (code) => {
|
|
188
|
+
if (code !== null && code !== 0)
|
|
189
|
+
finish({ opened: false, command: opener.command, problem: `${opener.command} exited ${code}` });
|
|
190
|
+
});
|
|
191
|
+
child.unref();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Is a Sealkeep local API still listening on `port`, and is it *this* vault's?
|
|
196
|
+
*
|
|
197
|
+
* Two questions, because a recorded port answers neither. A port is reused by
|
|
198
|
+
* whatever process grabs it next, so `/health` first establishes that the
|
|
199
|
+
* listener is one of ours at all. Then a `/v1/status` call carrying this
|
|
200
|
+
* vault's token establishes that it reads the same token file — that is, that
|
|
201
|
+
* it serves this data dir and not a second vault's. 401 is the only answer
|
|
202
|
+
* that means "not this vault": every other status got past the token check,
|
|
203
|
+
* including the 404 a data dir with no vault yet would return.
|
|
204
|
+
*/
|
|
205
|
+
export async function servesThisVault(port, token, fetchImpl = fetch) {
|
|
206
|
+
try {
|
|
207
|
+
const health = await fetchImpl(`${uiProbeOrigin(port)}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
208
|
+
if (!health.ok)
|
|
209
|
+
return false;
|
|
210
|
+
const body = await health.json().catch(() => null);
|
|
211
|
+
if (body?.mode !== "local-api")
|
|
212
|
+
return false;
|
|
213
|
+
const authorized = await fetchImpl(`${uiProbeOrigin(port)}/v1/status`, {
|
|
214
|
+
headers: { authorization: `Bearer ${token}` },
|
|
215
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
216
|
+
});
|
|
217
|
+
return authorized.status !== 401;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Starts the local UI, or points at the one already running for this vault.
|
|
225
|
+
*
|
|
226
|
+
* Reuse is not an optimisation: a second server would mint no second token but
|
|
227
|
+
* would leave two listeners and two records for one vault, and whichever one
|
|
228
|
+
* the person closed first would look like "the UI stopped working". Finding
|
|
229
|
+
* the running one and opening a browser at it is the behaviour that matches
|
|
230
|
+
* what someone typing the command twice actually meant.
|
|
231
|
+
*/
|
|
232
|
+
export async function startUi(dataDir, options = {}) {
|
|
233
|
+
const token = await localApiToken(dataDir);
|
|
234
|
+
const open = options.open ?? ((url) => openInBrowser(url));
|
|
235
|
+
const wantsBrowser = options.openBrowser ?? true;
|
|
236
|
+
const noBrowser = { opened: false, command: null, declined: true };
|
|
237
|
+
/**
|
|
238
|
+
* A browser is the last step and the least important one: by the time it
|
|
239
|
+
* runs there is a working server that a person can still reach by hand. So
|
|
240
|
+
* no failure here — not even an opener that throws rather than reporting —
|
|
241
|
+
* is allowed to escape and leave that server running with nobody told about it.
|
|
242
|
+
*/
|
|
243
|
+
const launch = async (url) => {
|
|
244
|
+
if (!wantsBrowser)
|
|
245
|
+
return noBrowser;
|
|
246
|
+
try {
|
|
247
|
+
return await open(url);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
return { opened: false, command: null, problem: error instanceof Error ? error.message : "could not open a browser" };
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const recorded = (options.reuse ?? true) ? await readUiRecord(dataDir) : null;
|
|
254
|
+
if (recorded && await servesThisVault(recorded.port, token, options.fetchImpl ?? fetch)) {
|
|
255
|
+
const url = uiUrl(recorded.port, token);
|
|
256
|
+
return {
|
|
257
|
+
port: recorded.port, origin: uiOrigin(recorded.port), address: LOOPBACK_HOST, url, reused: true, running: recorded,
|
|
258
|
+
browser: await launch(url),
|
|
259
|
+
// Another process owns that server; stopping it from here would close a
|
|
260
|
+
// window somebody else's terminal claims to be holding open.
|
|
261
|
+
close: async () => { }
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const server = createLocalApiServer(dataDir, token);
|
|
265
|
+
// The stable port is a preference, not a requirement. Something else holding
|
|
266
|
+
// 7477 is not this user's problem to solve before they can see their vault,
|
|
267
|
+
// so a busy port falls back to whatever is free rather than failing.
|
|
268
|
+
const wanted = options.port ?? DEFAULT_UI_PORT;
|
|
269
|
+
await new Promise((ready, broken) => {
|
|
270
|
+
const settle = () => { server.off("error", retry); ready(); };
|
|
271
|
+
const retry = (error) => {
|
|
272
|
+
if (wanted === 0 || (error.code !== "EADDRINUSE" && error.code !== "EACCES")) {
|
|
273
|
+
broken(error);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
server.once("error", broken);
|
|
277
|
+
server.listen(0, LOOPBACK_HOST, settle);
|
|
278
|
+
};
|
|
279
|
+
server.once("error", retry);
|
|
280
|
+
server.listen(wanted, LOOPBACK_HOST, settle);
|
|
281
|
+
});
|
|
282
|
+
const { port } = server.address();
|
|
283
|
+
await writeUiRecord(dataDir, { port, pid: process.pid, startedAt: new Date().toISOString() });
|
|
284
|
+
const url = uiUrl(port, token);
|
|
285
|
+
return {
|
|
286
|
+
port, origin: uiOrigin(port), address: server.address().address, url, reused: false, running: null,
|
|
287
|
+
browser: await launch(url),
|
|
288
|
+
close: async () => {
|
|
289
|
+
await clearUiRecord(dataDir, port);
|
|
290
|
+
await new Promise((done) => server.close(() => done()));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
package/dist/src/ui.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal presentation.
|
|
3
|
+
*
|
|
4
|
+
* Every command prints for a person by default and for a machine with `--json`.
|
|
5
|
+
* Colour is used to encode one thing only — state — so output stays readable when
|
|
6
|
+
* it is piped, redirected, or read by someone who cannot distinguish the hues.
|
|
7
|
+
*/
|
|
8
|
+
export declare const bold: (value: string) => string;
|
|
9
|
+
export declare const dim: (value: string) => string;
|
|
10
|
+
export declare const blue: (value: string) => string;
|
|
11
|
+
export declare const green: (value: string) => string;
|
|
12
|
+
export declare const amber: (value: string) => string;
|
|
13
|
+
export declare const red: (value: string) => string;
|
|
14
|
+
export declare const underline: (value: string) => string;
|
|
15
|
+
/** State markers. Text-first so they survive `NO_COLOR` and screen readers. */
|
|
16
|
+
export declare const mark: {
|
|
17
|
+
ok: () => string;
|
|
18
|
+
warn: () => string;
|
|
19
|
+
fail: () => string;
|
|
20
|
+
bullet: () => string;
|
|
21
|
+
};
|
|
22
|
+
export declare function heading(text: string): string;
|
|
23
|
+
/** The recurring device: the line past which plaintext does not travel. */
|
|
24
|
+
export declare function vaultline(label?: string): string;
|
|
25
|
+
export declare function keyValue(pairs: [string, string][], indent?: string): string;
|
|
26
|
+
export declare function bytes(value: number): string;
|
|
27
|
+
export declare function relativeTime(iso: string): string;
|
|
28
|
+
/** Shortens a path from the left, so the filename always survives. */
|
|
29
|
+
export declare function shortPath(path: string, width?: number): string;
|
|
30
|
+
export type Column<T> = {
|
|
31
|
+
header: string;
|
|
32
|
+
get: (row: T) => string;
|
|
33
|
+
align?: "left" | "right";
|
|
34
|
+
};
|
|
35
|
+
export declare function table<T>(rows: readonly T[], columns: Column<T>[], empty: string): string;
|
|
36
|
+
export declare function stripAnsi(value: string): string;
|
|
37
|
+
/** Frames something the user must copy down before continuing. */
|
|
38
|
+
export declare function callout(title: string, body: string[]): string;
|
|
39
|
+
export declare function steps(items: string[]): string;
|
|
40
|
+
export declare function hint(text: string): string;
|
|
41
|
+
export declare function command(text: string): string;
|
package/dist/src/ui.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal presentation.
|
|
3
|
+
*
|
|
4
|
+
* Every command prints for a person by default and for a machine with `--json`.
|
|
5
|
+
* Colour is used to encode one thing only — state — so output stays readable when
|
|
6
|
+
* it is piped, redirected, or read by someone who cannot distinguish the hues.
|
|
7
|
+
*/
|
|
8
|
+
const NO_COLOR = process.env.NO_COLOR !== undefined || process.env.TERM === "dumb";
|
|
9
|
+
const useColor = () => process.stdout.isTTY === true && !NO_COLOR;
|
|
10
|
+
const wrap = (open, close) => (value) => (useColor() ? `[${open}m${value}[${close}m` : value);
|
|
11
|
+
export const bold = wrap("1", "22");
|
|
12
|
+
export const dim = wrap("2", "22");
|
|
13
|
+
export const blue = wrap("38;5;33", "39");
|
|
14
|
+
export const green = wrap("38;5;29", "39");
|
|
15
|
+
export const amber = wrap("38;5;172", "39");
|
|
16
|
+
export const red = wrap("38;5;160", "39");
|
|
17
|
+
export const underline = wrap("4", "24");
|
|
18
|
+
/** State markers. Text-first so they survive `NO_COLOR` and screen readers. */
|
|
19
|
+
export const mark = {
|
|
20
|
+
ok: () => green("ok"),
|
|
21
|
+
warn: () => amber("warn"),
|
|
22
|
+
fail: () => red("fail"),
|
|
23
|
+
bullet: () => dim("·")
|
|
24
|
+
};
|
|
25
|
+
export function heading(text) {
|
|
26
|
+
return `\n${bold(text)}\n${dim("─".repeat(Math.min(text.length + 12, 60)))}`;
|
|
27
|
+
}
|
|
28
|
+
/** The recurring device: the line past which plaintext does not travel. */
|
|
29
|
+
export function vaultline(label = "plaintext ends here") {
|
|
30
|
+
return `${dim("─".repeat(4))}${dim("┤")} ${blue(label)} ${dim("├")}${dim("─".repeat(Math.max(4, 52 - label.length)))}`;
|
|
31
|
+
}
|
|
32
|
+
export function keyValue(pairs, indent = " ") {
|
|
33
|
+
const width = Math.max(...pairs.map(([key]) => key.length));
|
|
34
|
+
return pairs.map(([key, value]) => `${indent}${dim(key.padEnd(width))} ${value}`).join("\n");
|
|
35
|
+
}
|
|
36
|
+
export function bytes(value) {
|
|
37
|
+
if (!Number.isFinite(value))
|
|
38
|
+
return "—";
|
|
39
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
40
|
+
let size = value;
|
|
41
|
+
let unit = 0;
|
|
42
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
43
|
+
size /= 1024;
|
|
44
|
+
unit += 1;
|
|
45
|
+
}
|
|
46
|
+
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
|
|
47
|
+
}
|
|
48
|
+
export function relativeTime(iso) {
|
|
49
|
+
const delta = Date.now() - Date.parse(iso);
|
|
50
|
+
if (!Number.isFinite(delta))
|
|
51
|
+
return "—";
|
|
52
|
+
const minutes = Math.round(delta / 60_000);
|
|
53
|
+
if (minutes < 1)
|
|
54
|
+
return "just now";
|
|
55
|
+
if (minutes < 60)
|
|
56
|
+
return `${minutes}m ago`;
|
|
57
|
+
const hours = Math.round(minutes / 60);
|
|
58
|
+
if (hours < 24)
|
|
59
|
+
return `${hours}h ago`;
|
|
60
|
+
const days = Math.round(hours / 24);
|
|
61
|
+
return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`;
|
|
62
|
+
}
|
|
63
|
+
/** Shortens a path from the left, so the filename always survives. */
|
|
64
|
+
export function shortPath(path, width = 46) {
|
|
65
|
+
const home = process.env.HOME;
|
|
66
|
+
const display = home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
67
|
+
return display.length <= width ? display : `…${display.slice(-(width - 1))}`;
|
|
68
|
+
}
|
|
69
|
+
export function table(rows, columns, empty) {
|
|
70
|
+
if (rows.length === 0)
|
|
71
|
+
return ` ${dim(empty)}`;
|
|
72
|
+
const cells = rows.map((row) => columns.map((column) => column.get(row)));
|
|
73
|
+
const widths = columns.map((column, index) => Math.max(column.header.length, ...cells.map((row) => stripAnsi(row[index]).length)));
|
|
74
|
+
const pad = (value, width, align) => {
|
|
75
|
+
const padding = " ".repeat(Math.max(0, width - stripAnsi(value).length));
|
|
76
|
+
return align === "right" ? padding + value : value + padding;
|
|
77
|
+
};
|
|
78
|
+
const header = ` ${columns.map((column, index) => dim(pad(column.header.toUpperCase(), widths[index], column.align))).join(" ")}`;
|
|
79
|
+
const body = cells.map((row) => ` ${row.map((value, index) => pad(value, widths[index], columns[index].align)).join(" ")}`);
|
|
80
|
+
return [header, ...body].join("\n");
|
|
81
|
+
}
|
|
82
|
+
export function stripAnsi(value) {
|
|
83
|
+
// eslint-disable-next-line no-control-regex
|
|
84
|
+
return value.replace(/\[[0-9;]*m/g, "");
|
|
85
|
+
}
|
|
86
|
+
/** Frames something the user must copy down before continuing. */
|
|
87
|
+
export function callout(title, body) {
|
|
88
|
+
const width = Math.max(title.length, ...body.map((line) => stripAnsi(line).length)) + 4;
|
|
89
|
+
const top = `┌${"─".repeat(width)}┐`;
|
|
90
|
+
const bottom = `└${"─".repeat(width)}┘`;
|
|
91
|
+
const line = (value) => `│ ${value}${" ".repeat(Math.max(0, width - stripAnsi(value).length - 4))} │`;
|
|
92
|
+
return [blue(top), line(bold(title)), line(""), ...body.map(line), blue(bottom)].join("\n");
|
|
93
|
+
}
|
|
94
|
+
export function steps(items) {
|
|
95
|
+
return items.map((item, index) => ` ${blue(String(index + 1))} ${item}`).join("\n");
|
|
96
|
+
}
|
|
97
|
+
export function hint(text) {
|
|
98
|
+
return `${dim("→")} ${text}`;
|
|
99
|
+
}
|
|
100
|
+
export function command(text) {
|
|
101
|
+
return bold(text);
|
|
102
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type UpdateArtifact = {
|
|
2
|
+
name: string;
|
|
3
|
+
sha256: string;
|
|
4
|
+
bytes: number;
|
|
5
|
+
};
|
|
6
|
+
export type UpdateManifest = {
|
|
7
|
+
version: 1;
|
|
8
|
+
product: "vaultline";
|
|
9
|
+
release: string;
|
|
10
|
+
publishedAt: string;
|
|
11
|
+
artifacts: UpdateArtifact[];
|
|
12
|
+
};
|
|
13
|
+
export type SignedManifest = {
|
|
14
|
+
manifest: UpdateManifest;
|
|
15
|
+
keyId: string;
|
|
16
|
+
algorithm: "ed25519";
|
|
17
|
+
signature: string;
|
|
18
|
+
};
|
|
19
|
+
/** Deterministic bytes to sign: object keys sorted, no incidental whitespace. */
|
|
20
|
+
export declare function canonicalize(value: unknown): string;
|
|
21
|
+
export declare function signManifest(manifest: UpdateManifest, privateKeyPem: string, keyId: string): SignedManifest;
|
|
22
|
+
/**
|
|
23
|
+
* Verifies a release manifest against a pinned key set. An unknown key id fails
|
|
24
|
+
* closed: a signature is only meaningful against a key the client already trusts.
|
|
25
|
+
*/
|
|
26
|
+
export declare function verifyManifest(signed: SignedManifest, trustedKeys: Record<string, string>): UpdateManifest;
|
|
27
|
+
/** Blocks rollback: a signed but older manifest must never be applied automatically. */
|
|
28
|
+
export declare function assertNotDowngrade(candidate: string, current: string): void;
|
|
29
|
+
/** Confirms a downloaded artifact matches the signed manifest entry byte for byte. */
|
|
30
|
+
export declare function verifyArtifact(path: string, entry: UpdateArtifact): Promise<void>;
|