sealkeep 0.11.0 → 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 +44 -0
- package/dist/packages/sealkeep-crypto/src/recipients.d.ts +1 -0
- package/dist/packages/sealkeep-crypto/src/recipients.js +52 -12
- package/dist/src/cli.js +55 -15
- package/dist/src/doctor.js +18 -2
- package/dist/src/release-check.d.ts +11 -0
- package/dist/src/release-check.js +117 -0
- package/dist/src/search.js +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
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
|
+
|
|
21
|
+
## 0.11.1 — 2026-09-18 — an upgrade keeps what you already sealed
|
|
22
|
+
|
|
23
|
+
Found by installing the published 0.9.0 from npm, building a vault with it,
|
|
24
|
+
and upgrading in place to the published 0.11.0.
|
|
25
|
+
|
|
26
|
+
- **Archives sealed before the rename open again.** A recipient is located in
|
|
27
|
+
an envelope by hashing a label with its salt, and renaming the product from
|
|
28
|
+
Vaultline to Sealkeep changed that label — so every archive written by 0.9.0
|
|
29
|
+
or earlier answered "no recipient could be opened with the supplied secret"
|
|
30
|
+
under 0.11.0, with the right phrase and intact bytes. Nothing was lost and
|
|
31
|
+
nothing was corrupt; the key could not be found to try. Both names are now
|
|
32
|
+
accepted when opening, forever, and only the current one is written. Device
|
|
33
|
+
keys were affected the same way, through their identifier and their key
|
|
34
|
+
exchange, and are fixed with them.
|
|
35
|
+
- **Content search survives the upgrade.** The sealed index was named
|
|
36
|
+
`content-index.vlindex` before the rename and is read as
|
|
37
|
+
`content-index.skindex` now, so search answered "no local content index"
|
|
38
|
+
while `index status` and `sealkeep doctor` — which read the coverage file,
|
|
39
|
+
whose name never changed — both insisted every archive was searchable. The
|
|
40
|
+
index is adopted under its current name on first use, so a search, a status
|
|
41
|
+
and a build all see the work the previous release did instead of silently
|
|
42
|
+
discarding it.
|
|
43
|
+
- `sealkeep doctor` fails when an agent's memory hooks are installed and the
|
|
44
|
+
service that prepares memory ran and then stopped. The hooks fail open by
|
|
45
|
+
design, so that combination gives every session no memory and no reason; it
|
|
46
|
+
happened on a test machine whose daemon had died, and nothing said so. A
|
|
47
|
+
vault that never started the service is left alone: archiving by hand is a
|
|
48
|
+
complete way to use this.
|
|
49
|
+
|
|
6
50
|
## 0.11.0 — 2026-09-16 — automatic memory that works, and an append-only index
|
|
7
51
|
|
|
8
52
|
**Automatic recall now works on a real, long-lived vault.** The hook lane
|
|
@@ -4,6 +4,7 @@ export declare class CryptoError extends Error {
|
|
|
4
4
|
readonly code: string;
|
|
5
5
|
constructor(code: string, message: string);
|
|
6
6
|
}
|
|
7
|
+
export declare function legacyKeyRecipientId(publicKeyRaw: Buffer): string;
|
|
7
8
|
/** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
|
|
8
9
|
export declare function zeroize(...buffers: Buffer[]): void;
|
|
9
10
|
export declare function derivePhraseKey(phrase: string, salt: Buffer, params?: ScryptParams): Buffer;
|
|
@@ -11,6 +11,29 @@ export class CryptoError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
const b64 = (value) => value.toString("base64");
|
|
13
13
|
const unb64 = (value) => Buffer.from(value, "base64");
|
|
14
|
+
/**
|
|
15
|
+
* Recipient identifiers, and the names this product used to publish under.
|
|
16
|
+
*
|
|
17
|
+
* A recipient is found in an envelope by hashing a label with its salt or its
|
|
18
|
+
* public key, so the label is part of the on-disk format: renaming the product
|
|
19
|
+
* from Vaultline to Sealkeep changed every identifier, and an archive sealed
|
|
20
|
+
* by the older release stopped matching any recipient at all. Its bytes are
|
|
21
|
+
* fine and its phrase is right; nothing could find the key to try. Both names
|
|
22
|
+
* are therefore accepted when opening, forever, and only the current one is
|
|
23
|
+
* ever written.
|
|
24
|
+
*/
|
|
25
|
+
const PHRASE_RECIPIENT_LABEL = "sealkeep-phrase-recipient:v2";
|
|
26
|
+
const LEGACY_PHRASE_RECIPIENT_LABEL = "vaultline-phrase-recipient:v2";
|
|
27
|
+
const KEY_RECIPIENT_LABEL = "sealkeep-key-recipient:v2";
|
|
28
|
+
const LEGACY_KEY_RECIPIENT_LABEL = "vaultline-key-recipient:v2";
|
|
29
|
+
const KEY_EXCHANGE_LABEL = "sealkeep-recipient:v2";
|
|
30
|
+
const LEGACY_KEY_EXCHANGE_LABEL = "vaultline-recipient:v2";
|
|
31
|
+
function legacyPhraseRecipientId(salt) {
|
|
32
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(LEGACY_PHRASE_RECIPIENT_LABEL), salt])).digest("hex").slice(0, 32);
|
|
33
|
+
}
|
|
34
|
+
export function legacyKeyRecipientId(publicKeyRaw) {
|
|
35
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(LEGACY_KEY_RECIPIENT_LABEL), publicKeyRaw])).digest("hex").slice(0, 32);
|
|
36
|
+
}
|
|
14
37
|
/** Overwrites key material in place. Best effort: it cannot reach copies the runtime made. */
|
|
15
38
|
export function zeroize(...buffers) {
|
|
16
39
|
for (const buffer of buffers)
|
|
@@ -38,10 +61,10 @@ function openKey(suite, kek, nonce, sealed, aad) {
|
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
export function phraseRecipientId(salt) {
|
|
41
|
-
return createHash("sha256").update(Buffer.concat([Buffer.from(
|
|
64
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(PHRASE_RECIPIENT_LABEL), salt])).digest("hex").slice(0, 32);
|
|
42
65
|
}
|
|
43
66
|
export function keyRecipientId(publicKeyRaw) {
|
|
44
|
-
return createHash("sha256").update(Buffer.concat([Buffer.from(
|
|
67
|
+
return createHash("sha256").update(Buffer.concat([Buffer.from(KEY_RECIPIENT_LABEL), publicKeyRaw])).digest("hex").slice(0, 32);
|
|
45
68
|
}
|
|
46
69
|
export function rawPublicKey(key) {
|
|
47
70
|
// The last 32 bytes of an X25519 SPKI DER encoding are the raw public key.
|
|
@@ -94,7 +117,12 @@ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
|
|
|
94
117
|
for (const wrapped of wrappedKeys) {
|
|
95
118
|
if ("phrase" in unlock && wrapped.type === "phrase") {
|
|
96
119
|
const salt = unb64(wrapped.kdf.salt);
|
|
97
|
-
|
|
120
|
+
// Either name: an archive sealed before the rename carries the legacy
|
|
121
|
+
// identifier, and refusing it there loses the archive to its own owner.
|
|
122
|
+
const stored = Buffer.from(wrapped.id);
|
|
123
|
+
const matches = timingSafeEqual(Buffer.from(phraseRecipientId(salt)), stored)
|
|
124
|
+
|| timingSafeEqual(Buffer.from(legacyPhraseRecipientId(salt)), stored);
|
|
125
|
+
if (!matches) {
|
|
98
126
|
failures.push("phrase recipient id mismatch");
|
|
99
127
|
continue;
|
|
100
128
|
}
|
|
@@ -112,16 +140,28 @@ export function unwrapArchiveKey(wrappedKeys, suite, archiveId, unlock) {
|
|
|
112
140
|
if ("privateKey" in unlock && wrapped.type === "x25519") {
|
|
113
141
|
const privateKey = typeof unlock.privateKey === "string" ? createPrivateKey(unlock.privateKey) : unlock.privateKey;
|
|
114
142
|
const shared = diffieHellman({ privateKey, publicKey: x25519PublicKeyFromRaw(unb64(wrapped.ephemeralPublicKey)) });
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
143
|
+
const info = Buffer.concat([unb64(wrapped.recipientPublicKey), unb64(wrapped.ephemeralPublicKey)]);
|
|
144
|
+
// The key-exchange label moved with the rename too, so a device key
|
|
145
|
+
// wrapped by the older release derives a different key-encryption key.
|
|
146
|
+
// Try today's, then the one that was published before it.
|
|
147
|
+
let opened;
|
|
148
|
+
for (const label of [KEY_EXCHANGE_LABEL, LEGACY_KEY_EXCHANGE_LABEL]) {
|
|
149
|
+
const kek = Buffer.from(hkdfSync("sha256", shared, info, Buffer.from(label), KEY_BYTES));
|
|
150
|
+
try {
|
|
151
|
+
opened = openKey(suite, kek, unb64(wrapped.nonce), unb64(wrapped.ciphertext), wrapAad(archiveId, wrapped.id));
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
failures.push(error.code);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
zeroize(kek);
|
|
158
|
+
}
|
|
159
|
+
if (opened)
|
|
160
|
+
break;
|
|
124
161
|
}
|
|
162
|
+
zeroize(shared);
|
|
163
|
+
if (opened)
|
|
164
|
+
return opened;
|
|
125
165
|
}
|
|
126
166
|
}
|
|
127
167
|
throw new CryptoError("no_recipient", `No recipient could be opened with the supplied secret (${failures.length} attempted)`);
|
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
|
});
|
package/dist/src/doctor.js
CHANGED
|
@@ -331,10 +331,26 @@ export async function runDoctor(dataDir, env = process.env, home = env.HOME ?? e
|
|
|
331
331
|
// Hooks fail open, so a recall lane whose passes keep dying is invisible at
|
|
332
332
|
// the agent: memory is simply absent. The service's heartbeat is the one
|
|
333
333
|
// place that records it.
|
|
334
|
-
const
|
|
334
|
+
const recallBeat = await readHeartbeat(dataDir);
|
|
335
|
+
const recallLane = recallBeat?.context;
|
|
336
|
+
const recallService = liveness(recallBeat);
|
|
335
337
|
const { readHookUnlockFailure } = await import("./agent-context.js");
|
|
336
338
|
const unlockFailure = await readHookUnlockFailure(dataDir);
|
|
337
|
-
|
|
339
|
+
// A stopped service is the quietest failure of all. The hooks still run and
|
|
340
|
+
// still fail open, so every session simply gets no memory and no reason —
|
|
341
|
+
// exactly what happened on a rehearsal machine whose daemon had died: the
|
|
342
|
+
// requests queued and nobody drained them. Only worth saying when the hooks
|
|
343
|
+
// are installed, because a vault archived by hand has nothing to prepare.
|
|
344
|
+
// Only a service that RAN and stopped: a vault that never started one is
|
|
345
|
+
// being used by hand, which this product supports and says so elsewhere.
|
|
346
|
+
const hooksExpectRecall = supported.some((agent) => agent.hooksInstalled && !agent.hooksAimedElsewhere);
|
|
347
|
+
if (hooksExpectRecall && recallService.state === "stopped") {
|
|
348
|
+
checks.push({
|
|
349
|
+
name: "automatic-recall", status: "fail",
|
|
350
|
+
detail: `Your agents' memory hooks are installed, but the service that prepares memory is not running (${recallService.detail.replace(/^Not running\. /, "").replace(/^It /, "it ")}). Until it runs, every session starts with no memory and no error. Start it from Settings › Automatic archiving, or run \`sealkeep daemon\`.`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
else if (unlockFailure && Date.now() - Date.parse(unlockFailure.at) < 24 * 60 * 60_000) {
|
|
338
354
|
checks.push({
|
|
339
355
|
name: "automatic-recall", status: "fail",
|
|
340
356
|
detail: `The ${unlockFailure.agent === "codex" ? "Codex" : "Claude Code"} memory hook could not unlock this vault at ${unlockFailure.at.slice(0, 16).replace("T", " ")}: automatic recall and hook capture were silently off for that session, while archiving may have kept working. The hook ran with SEALKEEP_SECRET_BACKEND ${unlockFailure.backend ? `= ${unlockFailure.backend}` : "unset"}; the hooks and the background service must find the recovery phrase in the same secret backend (Settings › Unlocking, or \`sealkeep autopilot\`).`,
|
|
@@ -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
|
+
}
|
package/dist/src/search.js
CHANGED
|
@@ -75,6 +75,33 @@ function supersededArchiveIds(records) {
|
|
|
75
75
|
export const INDEX_EXTRACTION_POLICY = 3;
|
|
76
76
|
const indexPath = (dataDir) => join(dataDir, "index", "content-index.skindex");
|
|
77
77
|
const envelopePath = (dataDir) => join(dataDir, "index", "content-index.json");
|
|
78
|
+
/** What releases up to 0.9.0 named the sealed index, before the rename. */
|
|
79
|
+
const legacyIndexPath = (dataDir) => join(dataDir, "index", "content-index.vlindex");
|
|
80
|
+
/**
|
|
81
|
+
* Adopts an index file left by a pre-rename release.
|
|
82
|
+
*
|
|
83
|
+
* Only the NAME changed: the envelope beside it, the coverage file and the
|
|
84
|
+
* ciphertext are all still current. Upgrading therefore made content search
|
|
85
|
+
* answer "no local content index" while `index status` and doctor, which read
|
|
86
|
+
* coverage.json, kept insisting every archive was searchable — and because
|
|
87
|
+
* coverage looked complete, a rebuild believed it had nothing to do. The file
|
|
88
|
+
* is renamed on first use, which is why this is safe to call from a read.
|
|
89
|
+
*/
|
|
90
|
+
async function adoptLegacyIndexFile(dataDir) {
|
|
91
|
+
const current = indexPath(dataDir);
|
|
92
|
+
if (await stat(current).then(() => true, () => false))
|
|
93
|
+
return false;
|
|
94
|
+
const legacy = legacyIndexPath(dataDir);
|
|
95
|
+
if (!(await stat(legacy).then((info) => info.isFile(), () => false)))
|
|
96
|
+
return false;
|
|
97
|
+
try {
|
|
98
|
+
await rename(legacy, current);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
78
105
|
const coveragePath = (dataDir) => join(dataDir, "index", "coverage.json");
|
|
79
106
|
const INDEX_TEMP_STALE_MS = 24 * 60 * 60_000;
|
|
80
107
|
const OWNED_INDEX_TEMP = /^(content-index\.(?:skindex|json)|coverage\.json|(?:account|team)-publication\.pending\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/;
|
|
@@ -799,6 +826,7 @@ export async function buildContentIndex(dataDir, rawPhrase, options = {}) {
|
|
|
799
826
|
};
|
|
800
827
|
await readConfig(dataDir);
|
|
801
828
|
await cleanupStaleContentIndexTemps(dataDir);
|
|
829
|
+
await adoptLegacyIndexFile(dataDir);
|
|
802
830
|
const records = await listArchives(dataDir);
|
|
803
831
|
// Segments mode is decided once, up front: a vault either has a manifest
|
|
804
832
|
// with at least one segment (sealkeep index migrate created it) or it
|
|
@@ -3885,6 +3913,7 @@ export async function loadContentIndex(dataDir, rawPhrase, options = {}) {
|
|
|
3885
3913
|
options.signal?.throwIfAborted();
|
|
3886
3914
|
const phrase = canonicalPhrase(rawPhrase);
|
|
3887
3915
|
const identity = await localIndexFileIdentity(dataDir);
|
|
3916
|
+
await adoptLegacyIndexFile(dataDir);
|
|
3888
3917
|
const envelope = await readFile(envelopePath(dataDir), "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
3889
3918
|
const ciphertextPath = indexPath(dataDir);
|
|
3890
3919
|
const exists = await stat(ciphertextPath).then((entry) => entry.isFile()).catch(() => false);
|
|
@@ -4955,6 +4984,9 @@ async function rangedQueryIndex(dataDir, phrase, terms, options) {
|
|
|
4955
4984
|
}
|
|
4956
4985
|
export async function loadContentIndexForQuery(dataDir, rawPhrase, terms, options = {}) {
|
|
4957
4986
|
const phrase = canonicalPhrase(rawPhrase);
|
|
4987
|
+
// A vault upgraded from a pre-rename release still has its index under the
|
|
4988
|
+
// old file name; adopt it before anything asks whether an index exists.
|
|
4989
|
+
await adoptLegacyIndexFile(dataDir);
|
|
4958
4990
|
// Segments mode: every segment is opened and merged directly, never through
|
|
4959
4991
|
// the legacy blob's ranged directory or full-decrypt paths below. The
|
|
4960
4992
|
// personal-source-facts cache is skipped here — a term-less lookup (as
|
|
@@ -6289,6 +6321,10 @@ export async function search(dataDir, query, options = {}) {
|
|
|
6289
6321
|
*/
|
|
6290
6322
|
export async function indexCoverage(dataDir, options = {}) {
|
|
6291
6323
|
const records = await listArchives(dataDir);
|
|
6324
|
+
// Status must not report a searchable vault whose index file this release
|
|
6325
|
+
// cannot see (see adoptLegacyIndexFile): coverage.json survives a rename
|
|
6326
|
+
// and would otherwise claim full coverage while every search failed.
|
|
6327
|
+
await adoptLegacyIndexFile(dataDir);
|
|
6292
6328
|
const coverage = await readFile(coveragePath(dataDir), "utf8")
|
|
6293
6329
|
.then((raw) => JSON.parse(raw))
|
|
6294
6330
|
.catch(() => null);
|