sealkeep 0.11.1 → 0.11.2
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/CHANGELOG.md +15 -0
- package/dist/src/cli.js +55 -15
- package/dist/src/release-check.d.ts +11 -0
- package/dist/src/release-check.js +117 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
Notable changes, by published version. Sealkeep is pre-1.0: minor versions
|
|
4
4
|
may change behavior, and say so here when they do.
|
|
5
5
|
|
|
6
|
+
## 0.11.2 — 2026-09-18 — a release tells you when a newer one exists
|
|
7
|
+
|
|
8
|
+
- Sealkeep says, once a day at most, when a newer release is on npm and what
|
|
9
|
+
to run. A person finds out from the tool they already have open, or not at
|
|
10
|
+
all: 0.11.0 sat on the registry for two days unable to open a 0.11.1 vault's
|
|
11
|
+
predecessor, and nobody running it could learn the fix existed. The note
|
|
12
|
+
prints after the command's own output, never before it and never instead of
|
|
13
|
+
it, including when the command failed. The answer is cached per machine, so
|
|
14
|
+
a scripted loop makes no traffic; a failed check counts as the day's check,
|
|
15
|
+
so an offline machine stops asking. It is silent on every failure, skipped
|
|
16
|
+
for `--json`, hooks, the daemon, the MCP server and any non-terminal output,
|
|
17
|
+
and turned off for good with `SEALKEEP_NO_UPDATE_CHECK=1` (or the
|
|
18
|
+
conventional `NO_UPDATE_NOTIFIER=1`). Nothing is sent: it reads the same
|
|
19
|
+
public package metadata `npm view` reads.
|
|
20
|
+
|
|
6
21
|
## 0.11.1 — 2026-09-18 — an upgrade keeps what you already sealed
|
|
7
22
|
|
|
8
23
|
Found by installing the published 0.9.0 from npm, building a vault with it,
|
package/dist/src/cli.js
CHANGED
|
@@ -274,6 +274,27 @@ function usage() {
|
|
|
274
274
|
`${dim("Docs:")} README.md ${dim("·")} ${dim("Security:")} THREAT_MODEL.md\n`
|
|
275
275
|
].join("\n");
|
|
276
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* This install's own package manifest. The file sits one level up from
|
|
279
|
+
* dist/src but two from src/, so a fixed relative path is right in exactly one
|
|
280
|
+
* of development and the published package: walk up until our own is found.
|
|
281
|
+
*/
|
|
282
|
+
async function ownManifest() {
|
|
283
|
+
const { readFile } = await import("node:fs/promises");
|
|
284
|
+
const { dirname, join } = await import("node:path");
|
|
285
|
+
const { fileURLToPath } = await import("node:url");
|
|
286
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
287
|
+
for (let up = 0; up < 5; up += 1) {
|
|
288
|
+
try {
|
|
289
|
+
const found = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
290
|
+
if (found?.name === "sealkeep" || found?.name === "vaultline")
|
|
291
|
+
return found;
|
|
292
|
+
}
|
|
293
|
+
catch { /* keep walking */ }
|
|
294
|
+
dir = dirname(dir);
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
277
298
|
async function main() {
|
|
278
299
|
const [command, ...args] = process.argv.slice(2);
|
|
279
300
|
const json = args.includes("--json");
|
|
@@ -284,20 +305,7 @@ async function main() {
|
|
|
284
305
|
// The manifest sits one level up from dist/src but two from src/, so a fixed
|
|
285
306
|
// relative path is right in exactly one of dev and the published package.
|
|
286
307
|
// Walk up until we find our own manifest instead.
|
|
287
|
-
const
|
|
288
|
-
const { dirname, join } = await import("node:path");
|
|
289
|
-
const { fileURLToPath } = await import("node:url");
|
|
290
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
291
|
-
let manifest = null;
|
|
292
|
-
for (let up = 0; up < 5 && !manifest; up += 1) {
|
|
293
|
-
try {
|
|
294
|
-
const found = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
295
|
-
if (found?.name === "sealkeep" || found?.name === "vaultline")
|
|
296
|
-
manifest = found;
|
|
297
|
-
}
|
|
298
|
-
catch { /* keep walking */ }
|
|
299
|
-
dir = dirname(dir);
|
|
300
|
-
}
|
|
308
|
+
const manifest = await ownManifest();
|
|
301
309
|
if (!manifest)
|
|
302
310
|
fail("internal", "Could not read the Sealkeep package manifest");
|
|
303
311
|
print(json ? JSON.stringify({ name: manifest.name, version: manifest.version }, null, 2) : `${manifest.name} ${manifest.version}`);
|
|
@@ -3682,7 +3690,38 @@ const HINTS = {
|
|
|
3682
3690
|
// copy, if one exists, is unaffected and `sealkeep verify` proves it.
|
|
3683
3691
|
ciphertext_integrity_failed: "the local sealed file is damaged; nothing was written. If this archive has a stored copy, `sealkeep verify` checks it and `sealkeep open <ref> <destination>` reads it back"
|
|
3684
3692
|
};
|
|
3685
|
-
|
|
3693
|
+
/**
|
|
3694
|
+
* One line, after the command's own output, when a newer release exists.
|
|
3695
|
+
*
|
|
3696
|
+
* It runs last on purpose: a person came here to do something, and an
|
|
3697
|
+
* announcement that delays or replaces their answer is worse than no
|
|
3698
|
+
* announcement. Anything that goes wrong here — offline, blocked registry,
|
|
3699
|
+
* unreadable manifest — is silence, never an error and never an exit code.
|
|
3700
|
+
* Machine-readable runs, hooks and the daemon are excluded: nothing may
|
|
3701
|
+
* appear in output another program parses or in a lane nobody watches.
|
|
3702
|
+
*/
|
|
3703
|
+
async function noteNewerRelease() {
|
|
3704
|
+
const command = process.argv[2];
|
|
3705
|
+
const args = process.argv.slice(3);
|
|
3706
|
+
if (process.argv.includes("--json") || !process.stdout.isTTY)
|
|
3707
|
+
return;
|
|
3708
|
+
if (command === undefined || ["hook", "daemon", "mcp", "api", "--version", "-v", "version"].includes(command))
|
|
3709
|
+
return;
|
|
3710
|
+
try {
|
|
3711
|
+
const manifest = await ownManifest();
|
|
3712
|
+
const current = manifest?.version;
|
|
3713
|
+
if (!current)
|
|
3714
|
+
return;
|
|
3715
|
+
const dataDirFlag = args.indexOf("--data-dir");
|
|
3716
|
+
const dataDir = (dataDirFlag >= 0 ? args[dataDirFlag + 1] : undefined) ?? envVar("DATA_DIR") ?? defaultDataDir();
|
|
3717
|
+
const { newerReleaseThan, updateNotice } = await import("./release-check.js");
|
|
3718
|
+
const latest = await newerReleaseThan(current, dataDir);
|
|
3719
|
+
if (latest)
|
|
3720
|
+
console.error(`\n ${hint(updateNotice(latest, current))}\n`);
|
|
3721
|
+
}
|
|
3722
|
+
catch { /* a version check must never be the thing that fails */ }
|
|
3723
|
+
}
|
|
3724
|
+
main().then(noteNewerRelease, (error) => {
|
|
3686
3725
|
const { error: payload } = errorPayload(error);
|
|
3687
3726
|
console.error(`\n ${mark.fail()} ${payload.message}`);
|
|
3688
3727
|
const suggestion = HINTS[payload.code];
|
|
@@ -3690,4 +3729,5 @@ main().catch((error) => {
|
|
|
3690
3729
|
console.error(` ${hint(suggestion)}`);
|
|
3691
3730
|
console.error(` ${dim(payload.code)}\n`);
|
|
3692
3731
|
process.exitCode = 1;
|
|
3732
|
+
return noteNewerRelease();
|
|
3693
3733
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version to suggest, or null. Never throws, never blocks longer than its
|
|
3
|
+
* own timeout, and answers from cache when today's check already happened.
|
|
4
|
+
*/
|
|
5
|
+
export declare function newerReleaseThan(currentVersion: string, dataDir: string, options?: {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
fetchLatest?: (signal: AbortSignal) => Promise<string | null>;
|
|
9
|
+
}): Promise<string | null>;
|
|
10
|
+
/** The one line a person sees. Kept to a fact and the command that acts on it. */
|
|
11
|
+
export declare function updateNotice(latest: string, current: string): string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "A newer Sealkeep is out."
|
|
3
|
+
*
|
|
4
|
+
* Shipping 0.11.0 taught us why this is worth having: a release that could not
|
|
5
|
+
* open archives written by its predecessor sat on the registry for two days,
|
|
6
|
+
* and nobody running it had any way to learn that the fix existed. A person
|
|
7
|
+
* finds out from the tool they already have open, or not at all.
|
|
8
|
+
*
|
|
9
|
+
* The rules this follows, because a version check is a network call the user
|
|
10
|
+
* did not ask for:
|
|
11
|
+
* - never before the command runs, and never in its way: the check is a
|
|
12
|
+
* background read of a cached answer, and the note prints after the work;
|
|
13
|
+
* - at most one request a day, per machine, recorded in the vault's runtime
|
|
14
|
+
* directory, so a scripted loop cannot turn into a traffic generator;
|
|
15
|
+
* - never for a machine-readable run (`--json`), a hook, or a daemon;
|
|
16
|
+
* - silent on every failure. An offline laptop, a blocked registry and a
|
|
17
|
+
* proxy that answers with HTML all mean "no note", never an error;
|
|
18
|
+
* - nothing is sent but the request itself: no vault id, no identifiers, no
|
|
19
|
+
* telemetry. It reads the same public metadata `npm view` reads, and can be
|
|
20
|
+
* turned off for good with SEALKEEP_NO_UPDATE_CHECK=1.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
const REGISTRY_URL = "https://registry.npmjs.org/sealkeep/latest";
|
|
26
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60_000;
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 2_000;
|
|
28
|
+
const statePath = (dataDir) => join(dataDir, "runtime", "latest-release.json");
|
|
29
|
+
/** A dotted release, compared numerically; anything unparseable sorts lowest. */
|
|
30
|
+
function isNewer(candidate, current) {
|
|
31
|
+
const parts = (value) => {
|
|
32
|
+
const core = value.split(/[-+]/, 1)[0] ?? "";
|
|
33
|
+
const numbers = core.split(".").map((piece) => Number.parseInt(piece, 10));
|
|
34
|
+
return numbers.length === 3 && numbers.every((piece) => Number.isInteger(piece) && piece >= 0) ? numbers : null;
|
|
35
|
+
};
|
|
36
|
+
const left = parts(candidate);
|
|
37
|
+
const right = parts(current);
|
|
38
|
+
if (!left || !right)
|
|
39
|
+
return false;
|
|
40
|
+
for (let index = 0; index < 3; index += 1) {
|
|
41
|
+
if (left[index] !== right[index])
|
|
42
|
+
return left[index] > right[index];
|
|
43
|
+
}
|
|
44
|
+
// A pre-release of the same numbers is not an upgrade to offer.
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
async function readCached(dataDir) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(await readFile(statePath(dataDir), "utf8"));
|
|
50
|
+
if (typeof raw.version !== "string" || typeof raw.at !== "string")
|
|
51
|
+
return null;
|
|
52
|
+
return { version: raw.version, at: raw.at };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function writeCached(dataDir, value) {
|
|
59
|
+
const target = statePath(dataDir);
|
|
60
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
61
|
+
try {
|
|
62
|
+
await mkdir(join(dataDir, "runtime"), { recursive: true, mode: 0o700 });
|
|
63
|
+
await writeFile(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
64
|
+
await rename(temp, target);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// A read-only or full disk must never cost a command its result.
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function fetchLatest(signal) {
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(REGISTRY_URL, { signal, headers: { accept: "application/vnd.npm.install-v1+json, application/json" } });
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
return null;
|
|
78
|
+
const body = await response.json();
|
|
79
|
+
return typeof body.version === "string" && /^\d+\.\d+\.\d+/.test(body.version) ? body.version : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The version to suggest, or null. Never throws, never blocks longer than its
|
|
87
|
+
* own timeout, and answers from cache when today's check already happened.
|
|
88
|
+
*/
|
|
89
|
+
export async function newerReleaseThan(currentVersion, dataDir, options = {}) {
|
|
90
|
+
const env = options.env ?? process.env;
|
|
91
|
+
if (env.SEALKEEP_NO_UPDATE_CHECK === "1" || env.NO_UPDATE_NOTIFIER === "1" || env.CI === "true")
|
|
92
|
+
return null;
|
|
93
|
+
const now = options.now ?? Date.now;
|
|
94
|
+
const cached = await readCached(dataDir);
|
|
95
|
+
const fresh = cached && now() - Date.parse(cached.at) < CHECK_INTERVAL_MS;
|
|
96
|
+
if (fresh)
|
|
97
|
+
return isNewer(cached.version, currentVersion) ? cached.version : null;
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
100
|
+
let latest;
|
|
101
|
+
try {
|
|
102
|
+
latest = await (options.fetchLatest ?? fetchLatest)(controller.signal);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
}
|
|
107
|
+
// A failed check is recorded as "asked today" too: an offline machine must
|
|
108
|
+
// not retry on every single command.
|
|
109
|
+
await writeCached(dataDir, { version: latest ?? cached?.version ?? currentVersion, at: new Date(now()).toISOString() });
|
|
110
|
+
if (!latest)
|
|
111
|
+
return null;
|
|
112
|
+
return isNewer(latest, currentVersion) ? latest : null;
|
|
113
|
+
}
|
|
114
|
+
/** The one line a person sees. Kept to a fact and the command that acts on it. */
|
|
115
|
+
export function updateNotice(latest, current) {
|
|
116
|
+
return `Sealkeep ${latest} is available (you have ${current}). Update with: npm i -g sealkeep@latest`;
|
|
117
|
+
}
|