Skip to content

Commit a0b1c4f

Browse files
committed
feat(cli): vaultbase update — self-update with SHA-256 + cosign verify
Adds a new `vaultbase update` subcommand alongside `cluster` and `backup`. Pulls the latest signed release for the running platform, verifies integrity, and atomically swaps the binary in place. Flow: 1. Detect platform — linux-{x64,arm64,x64-musl}, macos-{x64,arm64}, windows-x64 2. Fetch release JSON from api.github.com/repos/vaultbase-sh/vaultbase 3. Version compare; refuse downgrade unless --allow-downgrade 4. Download binary + .sha256 + .sig + .pem to a temp dir 5. Verify SHA-256 — always; fails closed on mismatch 6. Verify cosign signature — when `cosign` is on PATH, fails closed on mismatch; warns + falls through to SHA-256-only when cosign is missing 7. atomic renameSync over process.execPath (Linux/macOS keep running off the old inode); EXDEV cross-mount falls back to copy+unlink 8. Print "restart to apply" Flags: --check check-only, exit 0 (in sync) or 1 (update available) --yes non-interactive --version X.Y.Z pin specific release --no-verify skip cosign even if available (warns) --allow-downgrade permit older version --quiet, --help Windows: replacing a running .exe is impossible; the command refuses with a clear "stop the daemon first" error. Docs page added to guides/deployment with cosign install instructions and cron-based weekly auto-update recipe. 637 tests pass; smoke-tested --help and --check end-to-end.
1 parent 6cb7dbf commit a0b1c4f

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,20 @@ async function main() {
167167
return;
168168
}
169169

170+
// `vaultbase update` — self-update. Pulls the latest signed release for
171+
// the running platform, verifies SHA-256 + cosign sig, atomically
172+
// replaces the binary. Skips server boot.
173+
if (process.argv[2] === "update") {
174+
const { runUpdate } = await import("./scripts/update.ts");
175+
try {
176+
await runUpdate(process.argv.slice(3));
177+
process.exit(0);
178+
} catch (e) {
179+
process.stderr.write(`vaultbase update: ${e instanceof Error ? e.message : String(e)}\n`);
180+
process.exit(2);
181+
}
182+
}
183+
170184
// `vaultbase backup --to <dest>` — atomic SQLite snapshot + push.
171185
// Skips server boot entirely so cron / one-shot ops can run alongside
172186
// a live `vaultbase` daemon (VACUUM INTO is concurrent-safe).

src/scripts/update.ts

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/**
2+
* `vaultbase update` — self-update CLI.
3+
*
4+
* Pulls the latest signed release for the running platform from GitHub,
5+
* verifies the SHA-256 (always) and cosign signature (when cosign is
6+
* available), then atomically replaces the running binary. The running
7+
* process keeps executing off the old inode; restart to pick up the new
8+
* binary.
9+
*
10+
* vaultbase update interactive flow
11+
* vaultbase update --check print versions, exit 0 (in sync) or 1 (update available)
12+
* vaultbase update --yes non-interactive — don't prompt
13+
* vaultbase update --version 0.8.0 pin to a specific release
14+
* vaultbase update --no-verify SHA-256 only; skip cosign even if present (warns)
15+
* vaultbase update --allow-downgrade permit moving to an older version
16+
*
17+
* Safety:
18+
* - SHA-256 mismatch → abort, no swap
19+
* - cosign mismatch → abort, no swap
20+
* - permission denied on rename → clear error
21+
* - running on Windows → can't replace a running .exe; instructs operator
22+
*/
23+
24+
import { existsSync, mkdtempSync, renameSync, chmodSync, statSync, copyFileSync, unlinkSync } from "fs";
25+
import { tmpdir } from "os";
26+
import { join } from "path";
27+
import { spawnSync } from "child_process";
28+
import { VAULTBASE_VERSION } from "../core/version.ts";
29+
30+
interface UpdateFlags {
31+
check: boolean;
32+
yes: boolean;
33+
pinnedVersion: string | null;
34+
skipVerify: boolean;
35+
allowDowngrade: boolean;
36+
quiet: boolean;
37+
}
38+
39+
function parseFlags(argv: string[]): UpdateFlags {
40+
const flags: UpdateFlags = {
41+
check: false,
42+
yes: false,
43+
pinnedVersion: null,
44+
skipVerify: false,
45+
allowDowngrade: false,
46+
quiet: false,
47+
};
48+
for (let i = 0; i < argv.length; i++) {
49+
const a = argv[i] ?? "";
50+
if (a === "--check") flags.check = true;
51+
else if (a === "--yes" || a === "-y") flags.yes = true;
52+
else if (a === "--no-verify") flags.skipVerify = true;
53+
else if (a === "--allow-downgrade") flags.allowDowngrade = true;
54+
else if (a === "--quiet" || a === "-q") flags.quiet = true;
55+
else if (a === "--version" || a === "-v") flags.pinnedVersion = argv[++i] ?? null;
56+
else if (a.startsWith("--version=")) flags.pinnedVersion = a.slice("--version=".length);
57+
else if (a === "--help" || a === "-h") { printHelp(); process.exit(0); }
58+
else { process.stderr.write(`vaultbase update: unknown flag '${a}'\n`); process.exit(2); }
59+
}
60+
return flags;
61+
}
62+
63+
function printHelp(): void {
64+
process.stdout.write(`Usage: vaultbase update [flags]
65+
66+
Flags:
67+
--check Print versions and exit (0 = in sync, 1 = update available)
68+
--yes, -y Non-interactive; don't prompt for confirmation
69+
--version X.Y.Z, -v Pin to a specific release (default: latest)
70+
--no-verify Skip cosign signature check (SHA-256 still enforced)
71+
--allow-downgrade Permit moving to an older version
72+
--quiet, -q Suppress progress output
73+
--help, -h Show this message
74+
`);
75+
}
76+
77+
interface PlatformTarget {
78+
/** Filename under github releases — e.g. "vaultbase-linux-x64". */
79+
artifact: string;
80+
/** True for Windows where the running binary is locked. */
81+
windows: boolean;
82+
}
83+
84+
function detectPlatform(): PlatformTarget {
85+
const p = process.platform;
86+
const a = process.arch;
87+
if (p === "win32" && a === "x64") return { artifact: "vaultbase-windows-x64.exe", windows: true };
88+
if (p === "darwin" && a === "x64") return { artifact: "vaultbase-macos-x64", windows: false };
89+
if (p === "darwin" && a === "arm64") return { artifact: "vaultbase-macos-arm64", windows: false };
90+
if (p === "linux" && a === "arm64") return { artifact: "vaultbase-linux-arm64", windows: false };
91+
if (p === "linux" && a === "x64") {
92+
// Detect musl (Alpine) vs glibc — different binary.
93+
const musl = isMusl();
94+
return { artifact: musl ? "vaultbase-linux-x64-musl" : "vaultbase-linux-x64", windows: false };
95+
}
96+
throw new Error(`unsupported platform: ${p}/${a} — file an issue with this output`);
97+
}
98+
99+
function isMusl(): boolean {
100+
if (existsSync("/etc/alpine-release")) return true;
101+
try {
102+
const r = spawnSync("ldd", ["--version"], { encoding: "utf8" });
103+
if (r.stdout && /musl/i.test(r.stdout)) return true;
104+
if (r.stderr && /musl/i.test(r.stderr)) return true;
105+
} catch { /* ignore */ }
106+
return false;
107+
}
108+
109+
interface Release {
110+
tag_name: string;
111+
body: string;
112+
published_at: string;
113+
assets: Array<{ name: string; browser_download_url: string; size: number }>;
114+
}
115+
116+
async function fetchRelease(version: string | null): Promise<Release> {
117+
const url = version
118+
? `https://api.github.com/repos/vaultbase-sh/vaultbase/releases/tags/v${version.replace(/^v/, "")}`
119+
: `https://api.github.com/repos/vaultbase-sh/vaultbase/releases/latest`;
120+
const res = await fetch(url, { headers: { accept: "application/vnd.github+json", "user-agent": `vaultbase-update/${VAULTBASE_VERSION}` } });
121+
if (res.status === 404) throw new Error(`no release found at ${url}`);
122+
if (!res.ok) throw new Error(`github API ${res.status} on ${url}`);
123+
return await res.json() as Release;
124+
}
125+
126+
function compareVersion(current: string, target: string): -1 | 0 | 1 {
127+
const norm = (s: string): number[] => s.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
128+
const a = norm(current);
129+
const b = norm(target);
130+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
131+
const x = a[i] ?? 0, y = b[i] ?? 0;
132+
if (x < y) return -1;
133+
if (x > y) return 1;
134+
}
135+
return 0;
136+
}
137+
138+
async function downloadTo(url: string, dest: string, log: (s: string) => void): Promise<void> {
139+
const res = await fetch(url, { headers: { "user-agent": `vaultbase-update/${VAULTBASE_VERSION}` }, redirect: "follow" });
140+
if (!res.ok) throw new Error(`download failed: ${res.status} ${url}`);
141+
const total = parseInt(res.headers.get("content-length") ?? "0", 10);
142+
const file = Bun.file(dest);
143+
const writer = file.writer();
144+
let received = 0;
145+
let lastPct = -1;
146+
if (!res.body) throw new Error("download failed: empty body");
147+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
148+
writer.write(chunk);
149+
received += chunk.length;
150+
if (total > 0) {
151+
const pct = Math.floor((received / total) * 100);
152+
if (pct !== lastPct && pct % 5 === 0) {
153+
log(` download: ${pct}% (${(received / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MiB)`);
154+
lastPct = pct;
155+
}
156+
}
157+
}
158+
await writer.end();
159+
}
160+
161+
async function sha256OfFile(path: string): Promise<string> {
162+
const buf = await Bun.file(path).arrayBuffer();
163+
const digest = await crypto.subtle.digest("SHA-256", buf);
164+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
165+
}
166+
167+
function hasCosign(): boolean {
168+
try {
169+
const r = spawnSync("cosign", ["version"], { encoding: "utf8" });
170+
return r.status === 0;
171+
} catch { return false; }
172+
}
173+
174+
function runCosignVerify(binPath: string, sigPath: string, certPath: string, repo: string, ref: string): boolean {
175+
const r = spawnSync("cosign", [
176+
"verify-blob",
177+
"--certificate", certPath,
178+
"--signature", sigPath,
179+
"--certificate-identity-regexp", `^https://github\\.com/${repo}/`,
180+
"--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
181+
binPath,
182+
], { encoding: "utf8" });
183+
if (r.status === 0) return true;
184+
process.stderr.write(`cosign verify failed:\n${r.stderr || r.stdout}\n`);
185+
void ref;
186+
return false;
187+
}
188+
189+
async function promptYesNo(question: string): Promise<boolean> {
190+
process.stdout.write(`${question} [y/N] `);
191+
// Bun exposes stdin as an async iterable of Buffers when invoked as a TTY.
192+
const stdin = process.stdin as unknown as AsyncIterable<Buffer>;
193+
for await (const chunk of stdin) {
194+
const s = chunk.toString("utf8").trim().toLowerCase();
195+
return s === "y" || s === "yes";
196+
}
197+
return false;
198+
}
199+
200+
export async function runUpdate(argv: string[]): Promise<void> {
201+
const flags = parseFlags(argv);
202+
const log = (s: string) => { if (!flags.quiet) process.stdout.write(`${s}\n`); };
203+
204+
const platform = detectPlatform();
205+
log(`vaultbase ${VAULTBASE_VERSION} on ${process.platform}/${process.arch}${platform.artifact.includes("musl") ? " (musl)" : ""}`);
206+
207+
log("checking for updates…");
208+
const release = await fetchRelease(flags.pinnedVersion);
209+
const target = release.tag_name.replace(/^v/, "");
210+
const cmp = compareVersion(VAULTBASE_VERSION, target);
211+
212+
if (cmp === 0) {
213+
log(`already on ${VAULTBASE_VERSION} — nothing to do.`);
214+
if (flags.check) process.exit(0);
215+
return;
216+
}
217+
if (cmp > 0 && !flags.allowDowngrade) {
218+
process.stderr.write(`vaultbase update: target ${target} is older than current ${VAULTBASE_VERSION}; pass --allow-downgrade to override\n`);
219+
process.exit(2);
220+
}
221+
222+
log(`update available: ${VAULTBASE_VERSION}${target}`);
223+
if (flags.check) process.exit(1);
224+
225+
const binAsset = release.assets.find((a) => a.name === platform.artifact);
226+
const sigAsset = release.assets.find((a) => a.name === `${platform.artifact}.sig`);
227+
const certAsset = release.assets.find((a) => a.name === `${platform.artifact}.pem`);
228+
const sumsAsset = release.assets.find((a) => a.name === `${platform.artifact}.sha256`);
229+
if (!binAsset) throw new Error(`release ${target} has no asset '${platform.artifact}'`);
230+
if (!sumsAsset) throw new Error(`release ${target} has no '${platform.artifact}.sha256'`);
231+
232+
if (!flags.yes) {
233+
log("");
234+
log(`This will replace the running binary at ${process.execPath}`);
235+
if (platform.windows) log("⚠ on Windows the running .exe is locked — you must stop vaultbase first.");
236+
log("");
237+
if (!await promptYesNo(`Update to ${target}?`)) {
238+
log("aborted.");
239+
process.exit(1);
240+
}
241+
}
242+
243+
if (platform.windows) {
244+
process.stderr.write(`vaultbase update: cannot replace a running .exe on Windows. Stop the daemon first, then run \`vaultbase update --yes\` again.\n`);
245+
process.exit(2);
246+
}
247+
248+
const tmp = mkdtempSync(join(tmpdir(), "vaultbase-update-"));
249+
log(`downloading to ${tmp}…`);
250+
251+
const binPath = join(tmp, platform.artifact);
252+
const sumsPath = join(tmp, `${platform.artifact}.sha256`);
253+
await downloadTo(binAsset.browser_download_url, binPath, log);
254+
await downloadTo(sumsAsset.browser_download_url, sumsPath, log);
255+
256+
// SHA-256 verify (always)
257+
log("verifying SHA-256…");
258+
const expectedSha = (await Bun.file(sumsPath).text()).trim().split(/\s+/)[0]?.toLowerCase() ?? "";
259+
const actualSha = await sha256OfFile(binPath);
260+
if (expectedSha !== actualSha) {
261+
throw new Error(`SHA-256 mismatch: expected ${expectedSha}, got ${actualSha}`);
262+
}
263+
log(" ✓ SHA-256 ok");
264+
265+
// Cosign verify (when cosign present and not skipped)
266+
if (!flags.skipVerify) {
267+
if (sigAsset && certAsset && hasCosign()) {
268+
const sigPath = join(tmp, `${platform.artifact}.sig`);
269+
const certPath = join(tmp, `${platform.artifact}.pem`);
270+
await downloadTo(sigAsset.browser_download_url, sigPath, log);
271+
await downloadTo(certAsset.browser_download_url, certPath, log);
272+
log("verifying cosign signature…");
273+
if (!runCosignVerify(binPath, sigPath, certPath, "vaultbase-sh/vaultbase", target)) {
274+
throw new Error("cosign signature verification failed — refusing to update");
275+
}
276+
log(" ✓ cosign ok");
277+
} else if (!hasCosign()) {
278+
process.stderr.write("⚠ cosign not in PATH — skipping signature verification (SHA-256 still enforced).\n");
279+
process.stderr.write(" Install cosign for cryptographic provenance: https://docs.sigstore.dev/cosign/installation\n");
280+
}
281+
} else {
282+
process.stderr.write("⚠ --no-verify: cosign signature NOT checked (SHA-256 still enforced).\n");
283+
}
284+
285+
// Atomic replace
286+
chmodSync(binPath, 0o755);
287+
const target_path = process.execPath;
288+
log(`installing to ${target_path}…`);
289+
try {
290+
// Linux/macOS: rename of running binary keeps the process running off the
291+
// old inode. The new binary is in place for the next exec.
292+
renameSync(binPath, target_path);
293+
} catch (e) {
294+
// Cross-device move (binary in /usr/local/bin, tmp in /tmp on a different mount).
295+
if ((e as NodeJS.ErrnoException).code === "EXDEV") {
296+
copyFileSync(binPath, target_path);
297+
try { unlinkSync(binPath); } catch { /* ignore */ }
298+
} else {
299+
throw e;
300+
}
301+
}
302+
// Sanity-check the new binary is executable + correct size.
303+
try {
304+
const s = statSync(target_path);
305+
if (s.size < 1_000_000) throw new Error(`installed binary is suspiciously small (${s.size} bytes)`);
306+
} catch (e) {
307+
throw new Error(`post-install stat failed: ${e instanceof Error ? e.message : String(e)}`);
308+
}
309+
310+
log("");
311+
log(`✓ updated to ${target}.`);
312+
log(` restart vaultbase to apply.`);
313+
log("");
314+
if (release.body) {
315+
log("Release notes:");
316+
for (const line of release.body.slice(0, 4000).split("\n")) log(` ${line}`);
317+
}
318+
}

0 commit comments

Comments
 (0)