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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,2035 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile } from "node:fs/promises";
3
+ import { archiveFile, configureRemoteStorage, defaultDataDir, initialize, listArchives, readConfig, vaultStatus, addRecipient, removeRecipient } from "./vault.js";
4
+ import { restoreArchive } from "./restore.js";
5
+ import { detectAgents, findTranscripts, hookConfig, hookEventFromStdin } from "./adapters.js";
6
+ import { setupVault } from "./onboarding.js";
7
+ import { providers } from "./control-plane.js";
8
+ import { ArchiveQueue } from "./queue.js";
9
+ import { drainQueue } from "./worker.js";
10
+ import { runDoctor } from "./doctor.js";
11
+ import { localApiTokenPath } from "./local-api.js";
12
+ import { startUi, readUiRecord, DEFAULT_UI_PORT, DISPLAY_HOST } from "./ui-server.js";
13
+ import { enroll } from "./enroll.js";
14
+ import { freePort } from "./net.js";
15
+ import { errorPayload, fail, isVaultlineError } from "./errors.js";
16
+ import { DRIVE_FOLDER_DEFAULT, PRODUCT_MARK, PRODUCT_NAME } from "./branding.js";
17
+ import { migrateVault, rewrapVault } from "./migrate.js";
18
+ import { uploadArchive, uploadPending } from "./upload.js";
19
+ import { applyRetention, approveReclamation, evaluateRetention, pruneRedundantArchives, retentionSettings, setRetentionPolicy } from "./retention.js";
20
+ import { startDaemon } from "./daemon.js";
21
+ import { applySyncRules, describeSyncRules, resolveSyncRules } from "./sync-rules.js";
22
+ import { buildContentIndex, dropContentIndex, search } from "./search.js";
23
+ import { readAudit, toCsv } from "./audit.js";
24
+ import { deleteProviderCredentials, loadProviderCredentials, storeProviderCredentials } from "./secrets.js";
25
+ import { keyRecipientId, rawPublicKey } from "../packages/vaultline-crypto/src/index.js";
26
+ import { generateKeyPairSync } from "node:crypto";
27
+ import { hostname } from "node:os";
28
+ import { readFile } from "node:fs/promises";
29
+ import { runTui } from "./tui.js";
30
+ import { assertNotDowngrade, verifyArtifact, verifyManifest } from "./update.js";
31
+ import { autopilotStatus, disableAutopilot, enableAutopilot } from "./autopilot.js";
32
+ import { liveness, readHeartbeat } from "./heartbeat.js";
33
+ import { hasPasswordLock, unwrapPhrase, wrapPhrase } from "./password-lock.js";
34
+ import { isMnemonic, PHRASE_WORDS, promptForPhrase, recoveryKit, verifyAgainstVault } from "./recovery.js";
35
+ import { setupPlan } from "./storage-setup.js";
36
+ import { connectGdrive } from "./providers/gdrive.js";
37
+ import { resolveRecoveryPhrase } from "./secrets.js";
38
+ import { amber, bold, blue, bytes, callout, command as cmd, dim, green, heading, hint, keyValue, mark, relativeTime, shortPath, steps, table, vaultline } from "./ui.js";
39
+ const VALUE_FLAGS = new Set(["--data-dir", "--recovery-phrase", "--agent", "--home", "--limit", "--executable", "--provider", "--bucket", "--prefix", "--region", "--older-than-days", "--status", "--max", "--port", "--overwrite", "--label", "--public-key", "--config-id", "--backend", "--endpoint", "--policy", "--grace-days", "--interval", "--manifest", "--artifact", "--key", "--current", "--api", "--token", "--group", "--cli-path", "--account-id", "--project", "--out", "--password"]);
40
+ function take(args, flag, fallback) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : fallback; }
41
+ function positionals(args) { return args.filter((value, index) => !value.startsWith("-") && !VALUE_FLAGS.has(args[index - 1] ?? "")); }
42
+ function required(value, message) { if (!value)
43
+ fail("invalid_argument", message); return value; }
44
+ function agentId(value) { const agent = required(value, "Agent must be codex or claude"); if (agent !== "codex" && agent !== "claude")
45
+ fail("invalid_argument", "Agent must be codex or claude"); return agent; }
46
+ /** Flag, then environment, then this machine's keystore — then, at a terminal, the vault password if one was set. */
47
+ async function unlock(dataDir, explicit) {
48
+ const config = await readConfig(dataDir).catch(() => null);
49
+ if (!config)
50
+ return explicit ?? process.env.VAULTLINE_RECOVERY_PHRASE;
51
+ const resolved = await resolveRecoveryPhrase(dataDir, config.vaultId, explicit);
52
+ if (resolved)
53
+ return resolved;
54
+ // A person at a terminal can trade the vault password for the phrase; a
55
+ // script cannot type one, so non-interactive callers keep the failure and
56
+ // the instructions they have always had.
57
+ if (process.stdin.isTTY && (await hasPasswordLock(dataDir))) {
58
+ const phrase = await unwrapPhrase(dataDir, await promptForPhrase("Vault password: "));
59
+ if (phrase !== null)
60
+ return phrase;
61
+ fail("recovery_phrase_mismatch", "That password does not open this vault. Nothing has changed.");
62
+ }
63
+ return undefined;
64
+ }
65
+ function readStdin() { return new Promise((done) => { let input = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { input += chunk; }); process.stdin.on("end", () => done(input)); }); }
66
+ const BRAND = `${blue(PRODUCT_MARK)} ${bold(PRODUCT_NAME)}`;
67
+ const print = (...lines) => console.log(lines.join("\n"));
68
+ /**
69
+ * The one screen the whole product depends on.
70
+ *
71
+ * The phrase is the only secret that cannot be reissued, reset, or recovered
72
+ * from anywhere — and until now it went past as a single line of prose in the
73
+ * middle of a successful command's output, which is how people lose it. Three
74
+ * things are being fixed here, all of them about transcription rather than
75
+ * decoration:
76
+ *
77
+ * - The words are numbered and laid out in the same three columns as the
78
+ * printed sheet `sealkeep recovery kit` produces, so copying from screen to
79
+ * paper is position-for-position rather than a re-reading of a long sentence.
80
+ * - What happens if it is lost is stated as a consequence, not a caveat.
81
+ * "Shown once, never stored" describes our behaviour; "nobody can open your
82
+ * archives, ever, including us" describes theirs.
83
+ * - The two commands that make the copy real — verify it, print a sheet for it
84
+ * — are named right underneath, because the moment someone will act on that
85
+ * advice is this one.
86
+ *
87
+ * A phrase supplied with --recovery-phrase may not be 24 words at all, so the
88
+ * grid is used only for a real mnemonic and anything else prints as one line.
89
+ */
90
+ function recoveryPhraseScreen(phrase) {
91
+ const words = phrase.trim().split(/\s+/);
92
+ const rows = Math.ceil(PHRASE_WORDS / 3);
93
+ const width = Math.max(...words.map((word) => word.length));
94
+ const cell = (index) => (words[index] ? `${String(index + 1).padStart(2, " ")}. ${bold(words[index].padEnd(width))}` : "");
95
+ const grid = isMnemonic(phrase)
96
+ ? Array.from({ length: rows }, (_, row) => [cell(row), cell(row + rows), cell(row + rows * 2)].filter(Boolean).join(" ").trimEnd())
97
+ : [bold(phrase)];
98
+ return [
99
+ callout(`Recovery phrase — write ${isMnemonic(phrase) ? `these ${words.length} words` : "this"} down now`, [
100
+ ...grid,
101
+ "",
102
+ dim("Shown once. It is not stored anywhere, so this is the only time"),
103
+ dim("it can be displayed — not by another command, not by support."),
104
+ "",
105
+ dim("Lose it and every archive stays sealed forever. Nobody can open"),
106
+ dim("them: not you, not us, not whoever holds the storage bucket.")
107
+ ]),
108
+ "",
109
+ keyValue([
110
+ ["Check your copy", cmd("sealkeep recovery verify")],
111
+ ["Print a sheet", cmd("sealkeep recovery kit --out recovery-kit.txt")]
112
+ ])
113
+ ].join("\n");
114
+ }
115
+ /**
116
+ * Holds the phrase on screen until a person says they have written it down.
117
+ *
118
+ * Only when a person is actually watching: without a TTY there is nobody to
119
+ * press a key, so a script, a hook, or CI would hang forever waiting for one.
120
+ */
121
+ async function pauseUntilWrittenDown() {
122
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
123
+ return;
124
+ const { createInterface } = await import("node:readline/promises");
125
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
126
+ try {
127
+ await rl.question(`\n ${dim("press enter once you have written it down")} `);
128
+ }
129
+ finally {
130
+ rl.close();
131
+ }
132
+ }
133
+ /**
134
+ * What a browser-opening command says for itself.
135
+ *
136
+ * The URL carries the bearer token in its fragment (see uiUrl in
137
+ * ui-server.ts), so it is deliberately *not* printed on the path where a
138
+ * browser did open: scrollback outlives the session, gets pasted into issues,
139
+ * and is captured whole by `script` and CI logs. The origin alone is enough to
140
+ * tell someone where their window is.
141
+ *
142
+ * When no browser could be opened, the same reasoning inverts: a server nobody
143
+ * can reach is worse than a token in scrollback, so the full URL is printed
144
+ * with what it is plainly labelled.
145
+ */
146
+ function printUi(handle, dataDir, options = {}) {
147
+ const where = bold(handle.origin.replace("http://", ""));
148
+ // Branded when it is the whole command; a plain line when it is the last
149
+ // step of one that has already introduced itself.
150
+ print(options.brand === false
151
+ ? `\n ${handle.reused ? "Already open at" : "Settings window on"} ${where}`
152
+ : `\n${BRAND} ${handle.reused ? "already running on" : "settings on"} ${where}`);
153
+ if (handle.browser.opened) {
154
+ print(` ${mark.ok()} Opened in your browser.`);
155
+ print(` ${dim(`Nothing opened? Visit ${handle.origin} and paste the token from ${shortPath(localApiTokenPath(dataDir), 46)}`)}`);
156
+ }
157
+ else {
158
+ // `--no-browser` is a choice; warning that a browser "could not be opened"
159
+ // would report a failure that never happened.
160
+ if (!handle.browser.declined)
161
+ print(` ${mark.warn()} ${(handle.browser.problem ?? "No browser could be opened").replace(/\.$/, "")}.`);
162
+ print(` ${dim("Open this link yourself. It carries the key to this window, so keep it off shared screens:")}`);
163
+ print(` ${handle.url}`);
164
+ }
165
+ if (handle.reused) {
166
+ print(` ${dim(`Started ${handle.running?.startedAt ? relativeTime(handle.running.startedAt) : "earlier"} by process ${handle.running?.pid ?? "?"}. Stop it there, not here.`)}`);
167
+ return;
168
+ }
169
+ print(` ${dim("Ctrl-C to stop. The window stops working when you do.")}\n`);
170
+ }
171
+ /** Closes the UI on the way out, so the next run does not find a record pointing at nothing. */
172
+ function holdUntilStopped(handle) {
173
+ for (const signal of ["SIGINT", "SIGTERM"]) {
174
+ process.on(signal, () => { void handle.close().then(() => process.exit(0)); });
175
+ }
176
+ }
177
+ const statusMark = (status) => (status === "pass" || status === "done" ? mark.ok() : status === "fail" || status === "failed" ? mark.fail() : status === "warn" ? mark.warn() : dim(status));
178
+ /** Leading glyph for a quota line — silent at "ok", since a plain number is not a warning. */
179
+ const quotaMarker = (level) => (level === "over" ? `${mark.fail()} ` : level === "ok" ? "" : `${mark.warn()} `);
180
+ function usage() {
181
+ return [
182
+ `\n${BRAND} ${dim("keep your agent history, not the disk usage")}`,
183
+ heading("Start here"),
184
+ keyValue([
185
+ ["start", "first run: choose free or paid, get your recovery phrase"],
186
+ ["enroll <code>", "set this machine up from the code in your account panel"],
187
+ ["ui", "open the settings window in your browser"],
188
+ ["autopilot", "set it up once and let it run itself"],
189
+ ["quickstart", "set up and queue existing sessions, without a background service"],
190
+ ["setup", "create a vault and a recovery kit"],
191
+ ["status", "what is archived, queued, and pending"],
192
+ ["doctor", "check this machine end to end"]
193
+ ]),
194
+ heading("Everyday"),
195
+ keyValue([
196
+ ["queue run", "encrypt everything waiting (needs the recovery phrase)"],
197
+ ["daemon", "watch, archive, upload, and reclaim continuously"],
198
+ ["archive <file>", "seal one file into the vault by hand"],
199
+ ["archive <file> --stream", "seal straight into your bucket, no local ciphertext"],
200
+ ["archive <file> --stream --fresh", "abandon an interrupted streamed upload and start over"],
201
+ ["scan <path>", "find pasted secrets in transcripts before they are sealed in"],
202
+ ["list", "browse archives"],
203
+ ["search <query>", "search metadata, or contents with --content"],
204
+ ["index build", "build the local content index that --content searches"],
205
+ ["index status", "what is searchable and what is not yet indexed"],
206
+ ["storage targets", "several storages at once, with limits, pins, and priority"],
207
+ ["mcp install", "let your agents search this history themselves"],
208
+ ["recover <id> <dest>", "restore original bytes; --native puts it back where it came from"],
209
+ ["recover <session-file>", "agent says a session file is missing? name it and it goes back where resume expects it"],
210
+ ["tui", "the same view in the terminal"]
211
+ ]),
212
+ heading("Storage and retention"),
213
+ keyValue([
214
+ ["cloud login/status", "sign in to managed storage (--code for a Google account), or check usage"],
215
+ ["cloud push <id>", "send one archive to managed storage"],
216
+ ["cloud archives", "list what managed storage is holding"],
217
+ ["cloud pull <ref> <dest>", "fetch one archive back out of managed storage"],
218
+ ["open <ref> <dest>", "fetch and decrypt one archive, on any machine"],
219
+ ["plans", "what the storage packages cost"],
220
+ ["verify", "prove every stored archive can still be read back"],
221
+ ["storage setup", "guided setup for S3, R2, GCS, or Google Drive"],
222
+ ["storage configure", "choose a bucket and prefix"],
223
+ ["storage connect gdrive", "sign in to Google Drive (drive.file: it sees only what it created)"],
224
+ ["storage credentials", "store provider keys in the OS keychain"],
225
+ ["upload --all", "upload and verify pending archives"],
226
+ ["retention policy", "decide when sources may be reclaimed"],
227
+ ["retention apply", "dry run; add --confirm to move sources to the trash"],
228
+ ["retention prune", "drop snapshots a newer archive already contains"],
229
+ ["retention offload", "remove local archives your bucket already holds"]
230
+ ]),
231
+ heading("Keys and trust"),
232
+ keyValue([
233
+ ["recovery seal", "store a sealed copy of your key with your account"],
234
+ ["recovery open", "read that sealed copy back, with your phrase"],
235
+ ["device keygen", "mint a device key"],
236
+ ["recipients", "list, add, or remove who can open new archives"],
237
+ ["rewrap", "apply the current recipient set to existing archives"],
238
+ ["recovery kit", "printable sheet for your recovery words"],
239
+ ["phrase rotate", "new recovery phrase; every archive re-keyed, nothing re-encrypted"],
240
+ ["password change", "new vault password; the phrase never appears"],
241
+ ["share <id>", "one archive as a bundle a passcode opens — no vault needed"],
242
+ ["recovery verify", "check the copy you wrote down still works"],
243
+ ["audit", "what Sealkeep did to your files"],
244
+ ["autopilot status/off", "check on, or stop, the background service"],
245
+ ["agents hook-config", "print a reviewable hook snippet"]
246
+ ]),
247
+ `\n${dim("Add --json to any command for machine-readable output.")}`,
248
+ `${dim("Docs:")} README.md ${dim("·")} ${dim("Security:")} THREAT_MODEL.md\n`
249
+ ].join("\n");
250
+ }
251
+ async function main() {
252
+ const [command, ...args] = process.argv.slice(2);
253
+ const dataDir = take(args, "--data-dir", defaultDataDir());
254
+ const json = args.includes("--json");
255
+ // --version is the first thing anyone types at an unfamiliar CLI, and it was
256
+ // answering "Unknown command". Read it from the manifest rather than hardcoding
257
+ // a second copy that can drift from what npm actually published.
258
+ if (command === "--version" || command === "-v" || command === "version") {
259
+ // The manifest sits one level up from dist/src but two from src/, so a fixed
260
+ // relative path is right in exactly one of dev and the published package.
261
+ // Walk up until we find our own manifest instead.
262
+ const { readFile } = await import("node:fs/promises");
263
+ const { dirname, join } = await import("node:path");
264
+ const { fileURLToPath } = await import("node:url");
265
+ let dir = dirname(fileURLToPath(import.meta.url));
266
+ let manifest = null;
267
+ for (let up = 0; up < 5 && !manifest; up += 1) {
268
+ try {
269
+ const found = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
270
+ if (found?.name === "sealkeep" || found?.name === "vaultline")
271
+ manifest = found;
272
+ }
273
+ catch { /* keep walking */ }
274
+ dir = dirname(dir);
275
+ }
276
+ if (!manifest)
277
+ fail("internal", "Could not read the Sealkeep package manifest");
278
+ print(json ? JSON.stringify({ name: manifest.name, version: manifest.version }, null, 2) : `${manifest.name} ${manifest.version}`);
279
+ return;
280
+ }
281
+ if (command === "help" || command === "--help" || command === "-h") {
282
+ print(usage());
283
+ return;
284
+ }
285
+ /**
286
+ * A bare `vaultline` on a machine with no vault is somebody who has just run
287
+ * `npm i -g vaultline`. Setting this product up belongs in a window, not in a
288
+ * wall of commands, so that case opens one.
289
+ *
290
+ * Three deliberate limits. An existing vault still prints help, because
291
+ * anyone who has got that far is using the CLI on purpose and having it
292
+ * launch a browser would be obnoxious. No TTY means a script, a hook, CI or
293
+ * an SSH session, and none of those can be left holding a foreground server
294
+ * waiting for a browser that will never open. And `--help` above still wins,
295
+ * so there is always a way to see the commands.
296
+ */
297
+ if (!command) {
298
+ const configured = await readConfig(dataDir).then(() => true).catch(() => false);
299
+ if (configured || !process.stdout.isTTY) {
300
+ print(usage());
301
+ return;
302
+ }
303
+ const handle = await startUi(dataDir, { port: 0 });
304
+ printUi(handle, dataDir);
305
+ if (handle.reused)
306
+ return;
307
+ holdUntilStopped(handle);
308
+ return;
309
+ }
310
+ if (command === "autopilot") {
311
+ const [action] = positionals(args);
312
+ if (action === "status") {
313
+ const status = await autopilotStatus(dataDir);
314
+ if (json) {
315
+ print(JSON.stringify(status, null, 2));
316
+ return;
317
+ }
318
+ const liveMark = status.live.state === "running" ? green("running") : status.live.state === "late" ? amber("late") : dim(status.live.state.replace("-", " "));
319
+ // The generic "never started" advice is wrong once the service exists: it is
320
+ // installed and simply has not reached its first check yet.
321
+ const liveDetail = status.running && status.live.state === "never-started"
322
+ ? "Installed. Waiting for its first check, which happens at login or within a minute."
323
+ : status.live.detail;
324
+ print(`\n${BRAND} ${status.running ? green("autopilot on") : dim("autopilot off")}\n`);
325
+ print(keyValue([
326
+ ["Right now", `${status.running && status.live.state === "never-started" ? dim("waiting") : liveMark} ${dim(liveDetail)}`],
327
+ ["Done so far", status.live.totals ? `${status.live.totals.archived} sealed ${dim("·")} ${status.live.totals.uploaded} uploaded ${dim("·")} ${status.live.totals.reclaimed} reclaimed${status.live.totals.failed ? ` ${dim("·")} ${mark.warn()} ${status.live.totals.failed} failed` : ""}` : dim("nothing yet")],
328
+ ["Service", status.running ? `${status.service.kind} ${dim(`· ${shortPath(status.service.path, 52)}`)}` : dim("not installed")],
329
+ ["Unlocks itself", status.phraseAvailable ? `${mark.ok()} yes` : `${mark.warn()} no — sessions will queue but stay unsealed`],
330
+ ["Policy", `${status.retention.policy} ${dim(`· older than ${status.retention.olderThanDays}d · ${status.retention.graceDays}d grace`)}`],
331
+ ["Bucket", status.hasRemoteTarget ? "configured" : dim("none — nothing will be reclaimed")],
332
+ ["Queue", `${status.queue.ready} waiting ${dim("·")} ${status.queue.done} sealed`]
333
+ ]));
334
+ print("");
335
+ return;
336
+ }
337
+ if (action === "off") {
338
+ const result = await disableAutopilot(dataDir);
339
+ if (json) {
340
+ print(JSON.stringify(result, null, 2));
341
+ return;
342
+ }
343
+ print(`\n ${mark.ok()} Autopilot off. The service is removed${result.forgotPhrase ? " and this machine has forgotten your phrase" : ""}.`);
344
+ print(` ${dim("Every archive is still here. Nothing was deleted.")}\n`);
345
+ return;
346
+ }
347
+ const dryRun = args.includes("--dry-run");
348
+ const result = await enableAutopilot(dataDir, {
349
+ phrase: take(args, "--recovery-phrase"),
350
+ remember: !args.includes("--no-remember"),
351
+ reclaim: !args.includes("--no-reclaim"),
352
+ olderThanDays: take(args, "--older-than-days") ? Number(take(args, "--older-than-days")) : undefined,
353
+ graceDays: take(args, "--grace-days") ? Number(take(args, "--grace-days")) : undefined,
354
+ home: take(args, "--home"),
355
+ dryRun
356
+ });
357
+ if (json) {
358
+ print(JSON.stringify(result, null, 2));
359
+ return;
360
+ }
361
+ print(`\n${BRAND}\n`);
362
+ if (result.recoveryPhrase) {
363
+ print(recoveryPhraseScreen(result.recoveryPhrase));
364
+ await pauseUntilWrittenDown();
365
+ }
366
+ print(heading(dryRun ? "What would happen" : "Autopilot is on"));
367
+ print(keyValue([
368
+ ["Watches", result.agents.length ? result.agents.join(" and ") : dim("no agents found yet")],
369
+ ["Found", result.found.sessions ? `${result.found.sessions} sessions ${dim(`· ${bytes(result.found.bytes)}`)}` : dim("nothing yet")],
370
+ ["Seals", result.remembered
371
+ ? `automatically ${dim(`· phrase kept in ${result.remembered.backend}`)}`
372
+ : dryRun && !args.includes("--no-remember")
373
+ ? `automatically ${dim("· the phrase would be kept in this machine's keystore")}`
374
+ : `${mark.warn()} needs VAULTLINE_RECOVERY_PHRASE`],
375
+ ["Reclaims", result.reclaimEnabled ? `after ${result.retention.olderThanDays}d ${dim(`+ ${result.retention.graceDays}d grace, once a remote copy is verified`)}` : dim("never — sync only")],
376
+ ["Starts", result.service.installed ? `at login ${dim(`· ${result.service.kind}`)}` : dryRun ? dim(`${result.service.kind} service at ${shortPath(result.service.path, 44)}`) : `${mark.warn()} not installed`]
377
+ ]));
378
+ for (const note of result.notes)
379
+ print(`\n ${mark.warn()} ${note}`);
380
+ if (!dryRun) {
381
+ print(`\n${vaultline()}`);
382
+ print(`${dim(" From here it runs on its own. Check on it with")} ${cmd("sealkeep autopilot status")}${dim(", or stop it with")} ${cmd("sealkeep autopilot off")}${dim(".")}\n`);
383
+ }
384
+ else {
385
+ print(`\n ${hint(`re-run without ${cmd("--dry-run")} to apply this`)}\n`);
386
+ }
387
+ return;
388
+ }
389
+ if (command === "quickstart") {
390
+ const result = await setupVault(dataDir, take(args, "--recovery-phrase"));
391
+ const installs = await detectAgents(take(args, "--home"));
392
+ const detected = installs.filter((install) => install.detected);
393
+ const found = (await Promise.all(detected.map((install) => findTranscripts(install.agent, take(args, "--home"), 500)))).flat();
394
+ const totalBytes = found.reduce((total, candidate) => total + candidate.bytes, 0);
395
+ // What leaves this machine is decided here, before anything is queued — see sync-rules.ts.
396
+ const { rules, source } = await resolveSyncRules(dataDir);
397
+ const { included, heldBack } = applySyncRules(found, rules);
398
+ const queue = new ArchiveQueue(dataDir);
399
+ let queued = 0;
400
+ for (const candidate of included) {
401
+ const { deduped } = await queue.enqueue({ sourcePath: candidate.path, agent: candidate.agent, event: "quickstart" });
402
+ if (!deduped)
403
+ queued += 1;
404
+ }
405
+ if (json) {
406
+ print(JSON.stringify({ ...result, detected: detected.map((i) => i.agent), found: found.length, heldBack: heldBack.length, totalBytes, queued }, null, 2));
407
+ return;
408
+ }
409
+ print(`\n${BRAND}\n`);
410
+ if (result.recoveryPhrase) {
411
+ print(recoveryPhraseScreen(result.recoveryPhrase));
412
+ await pauseUntilWrittenDown();
413
+ }
414
+ else {
415
+ print(` ${mark.ok()} Vault ${dim(result.vaultId.slice(0, 8))} already set up.\n`);
416
+ }
417
+ print(heading("What you have right now"));
418
+ print(found.length === 0
419
+ ? ` ${dim("No agent sessions found yet. Sealkeep will pick them up as you work.")}`
420
+ : ` ${bold(String(found.length))} sessions across ${detected.map((i) => i.agent).join(" and ")}, ${bold(bytes(totalBytes))} of history.\n ${queued} queued for encryption.`);
421
+ if (heldBack.length)
422
+ print(` ${dim(`Sync rules: uploading ${describeSyncRules(rules)}. ${heldBack.length} session${heldBack.length === 1 ? "" : "s"} stay local.`)}`);
423
+ if (source === "cache")
424
+ print(` ${dim("Could not reach your account, so the last known sync rules are in force.")}`);
425
+ print(heading("Next"));
426
+ print(steps([
427
+ `Encrypt what is queued: ${cmd(`VAULTLINE_RECOVERY_PHRASE="…" sealkeep queue run`)}`,
428
+ `Archive automatically: ${cmd("vaultline agents hook-config codex")} ${dim("(review, then merge)")}`,
429
+ `See it: ${cmd("sealkeep desktop")}`
430
+ ]));
431
+ print(`\n${vaultline()}\n${dim(" Everything below that line is ciphertext. Nothing above it leaves this machine.")}\n`);
432
+ return;
433
+ }
434
+ if (command === "start") {
435
+ const { modeOptions, start } = await import("./start.js");
436
+ const mode = take(args, "--mode");
437
+ const { interactive, onboard } = await import("./start-tui.js");
438
+ // No flags and a real terminal: walk it interactively instead of making
439
+ // someone read options and retype them as flags.
440
+ if (!mode && interactive()) {
441
+ print(`\n${BRAND}\n`);
442
+ const choice = await onboard();
443
+ const filled = choice.mode === "paid"
444
+ ? { ...choice, password: process.env.VAULTLINE_CLOUD_PASSWORD ?? await promptForPhrase("Choose a password (12+ characters): ") }
445
+ : choice;
446
+ const result = await start(dataDir, filled);
447
+ print(`\n${recoveryPhraseScreen(result.phrase)}`);
448
+ await pauseUntilWrittenDown();
449
+ print("");
450
+ for (const step of result.nextSteps)
451
+ print(` · ${step}`);
452
+ print("");
453
+ return;
454
+ }
455
+ if (!mode) {
456
+ print(`\n${BRAND}\n`);
457
+ for (const option of modeOptions()) {
458
+ print(bold(option.title), dim(option.summary), "");
459
+ print(" you provide");
460
+ for (const line of option.youProvide)
461
+ print(` · ${line}`);
462
+ print(" we provide");
463
+ for (const line of option.weProvide)
464
+ print(` · ${line}`);
465
+ print("");
466
+ }
467
+ print(dim("Choose one:"), " sealkeep start --mode free --provider gcs --bucket <name> --prefix sealkeep --project <gcp-project>", " sealkeep start --mode paid --email you@example.com --label \"my laptop\"", "");
468
+ return;
469
+ }
470
+ const result = mode === "paid"
471
+ ? await start(dataDir, {
472
+ mode: "paid",
473
+ email: take(args, "--email") ?? fail("invalid_argument", "--email is required for a paid account"),
474
+ password: process.env.VAULTLINE_CLOUD_PASSWORD ?? await promptForPhrase("Choose a password (12+ characters): "),
475
+ label: take(args, "--label", "this machine")
476
+ })
477
+ : await start(dataDir, {
478
+ mode: "free",
479
+ provider: take(args, "--provider", "s3"),
480
+ bucket: take(args, "--bucket") ?? fail("invalid_argument", "--bucket is required for the free tier"),
481
+ prefix: take(args, "--prefix", "vaultline"),
482
+ region: take(args, "--region"), accountId: take(args, "--account-id"), project: take(args, "--project")
483
+ });
484
+ if (json) {
485
+ print(JSON.stringify({ ...result, escrow: result.escrow ? "<sealed>" : undefined }, null, 2));
486
+ return;
487
+ }
488
+ print(`\n${BRAND}\n`);
489
+ print(recoveryPhraseScreen(result.phrase));
490
+ await pauseUntilWrittenDown();
491
+ print("");
492
+ for (const step of result.nextSteps)
493
+ print(` · ${step}`);
494
+ if (result.storageSetup)
495
+ print("", dim("Storage setup: run `sealkeep storage setup` for the full commands."));
496
+ print("");
497
+ return;
498
+ }
499
+ if (command === "enroll") {
500
+ const [code] = positionals(args);
501
+ const result = await enroll(dataDir, {
502
+ code: required(code, `Usage: sealkeep enroll <code> [--label "work laptop"]`),
503
+ // The panel shows this in the device list, so a machine that never says
504
+ // what it is called still arrives as something a human recognises.
505
+ label: take(args, "--label", hostname())
506
+ });
507
+ if (json) {
508
+ print(JSON.stringify(result, null, 2));
509
+ return;
510
+ }
511
+ const { quotaState, quotaMessage } = await import("./packages.js");
512
+ const quota = quotaState(result.account.usedBytes, result.account.quotaBytes);
513
+ const reclaims = result.settings.retention.policy === "archive-and-reclaim" || result.settings.retention.policy === "manual-approval";
514
+ print(`\n${BRAND}\n`);
515
+ print(` ${mark.ok()} Enrolled on the ${bold(result.account.plan)} plan ${dim(`· device ${result.deviceId}`)}`);
516
+ print(keyValue([
517
+ ["Storage", result.account.quotaBytes > 0 ? `${quotaMarker(quota.level)}${quotaMessage(quota)}${quota.level === "ok" ? "" : dim(" · sealkeep plans")}` : dim("no managed quota on this account")],
518
+ ["Uploads", describeSyncRules(result.settings.sync)],
519
+ ["Reclaims", reclaims ? `after ${result.settings.retention.olderThanDays}d ${dim(`+ ${result.settings.retention.graceDays}d grace, once a remote copy is verified`)}` : dim("never — sync only")]
520
+ ]));
521
+ // Those rules are now on disk, which is the point of fetching them during
522
+ // enrollment: what leaves this machine stays decidable with the network down.
523
+ if (result.settingsCached)
524
+ print(`\n ${dim("Cached on this machine, so they still hold when it is offline.")}`);
525
+ else
526
+ print(`\n ${mark.warn()} ${result.cacheWarning ?? "The account's settings could not be saved locally."}`);
527
+ // §5: enrollment ends in a window rather than a list of commands to type.
528
+ // --no-ui is the escape hatch for scripts and CI, matching --no-remember,
529
+ // --no-reclaim and --no-watch elsewhere; --json needs no flag because it
530
+ // has already returned above — a machine reading this output has no
531
+ // browser to open and must not be left holding a foreground server.
532
+ if (args.includes("--no-ui")) {
533
+ print(`\n ${hint(`${cmd("sealkeep ui")} opens the settings window when you want it`)}\n`);
534
+ return;
535
+ }
536
+ // The enrollment itself is already done and its code already spent, so a
537
+ // window that will not open must not be reported as a failed enrollment —
538
+ // the same reasoning enroll.ts applies to settings it could not cache.
539
+ let handle;
540
+ try {
541
+ handle = await startUi(dataDir);
542
+ }
543
+ catch (error) {
544
+ print(` ${mark.warn()} This machine is enrolled, but the settings window could not start: ${errorPayload(error).error.message}`);
545
+ print(` ${hint(`try ${cmd("sealkeep ui")}`)}\n`);
546
+ return;
547
+ }
548
+ printUi(handle, dataDir, { brand: false });
549
+ if (handle.reused)
550
+ return;
551
+ holdUntilStopped(handle);
552
+ return;
553
+ }
554
+ if (command === "cloud") {
555
+ const cloud = await import("./cloud.js");
556
+ const [action] = positionals(args);
557
+ if (action === "login") {
558
+ const email = take(args, "--email") ?? fail("invalid_argument", "--email is required");
559
+ // A one-time code from the panel is the only way in for an account that
560
+ // signed up with Google, and it works for password accounts too.
561
+ const code = take(args, "--code") ?? process.env.VAULTLINE_CLOUD_CODE;
562
+ const account = code
563
+ ? await cloud.loginWithCode(dataDir, { email: email, token: code })
564
+ : await cloud.login(dataDir, {
565
+ email: email,
566
+ password: process.env.VAULTLINE_CLOUD_PASSWORD ?? await promptForPhrase("Password: ")
567
+ });
568
+ if (json) {
569
+ print(JSON.stringify(account, null, 2));
570
+ return;
571
+ }
572
+ print(`\nSigned in as ${bold(account.email)} — ${account.plan} plan\n`);
573
+ return;
574
+ }
575
+ if (action === "logout") {
576
+ await cloud.logout(dataDir);
577
+ print("\nSigned out of Sealkeep Cloud.\n");
578
+ return;
579
+ }
580
+ if (action === "status" || !action) {
581
+ const account = await cloud.accountStatus(dataDir);
582
+ if (json) {
583
+ print(JSON.stringify(account, null, 2));
584
+ return;
585
+ }
586
+ const { quotaState, quotaMessage } = await import("./packages.js");
587
+ print(`\n${bold(account.email)} ${account.plan} plan ${account.state}\n`);
588
+ const state = quotaState(account.used_bytes, account.quota_bytes);
589
+ print(keyValue([
590
+ ["Storage", account.quota_bytes > 0 ? `${quotaMarker(state.level)}${quotaMessage(state)}${state.level === "ok" ? "" : dim(" · sealkeep plans")}` : dim("no managed quota on this account")]
591
+ ]));
592
+ print("");
593
+ return;
594
+ }
595
+ if (action === "push") {
596
+ const [, archiveId] = positionals(args);
597
+ if (!archiveId)
598
+ fail("invalid_argument", "Usage: sealkeep cloud push <archive-id>");
599
+ const result = await cloud.pushArchive(dataDir, archiveId).catch(async (error) => {
600
+ // cloud.ts's call() maps a 402 to a VaultlineError coded "forbidden"
601
+ // whose message is just whatever the control plane said — today a bare
602
+ // "Quota exceeded", no numbers, no next step. The message is the only
603
+ // signal available to tell that apart from an unrelated 403 without
604
+ // editing cloud.ts, so it is what we sniff.
605
+ if (isVaultlineError(error) && error.code === "forbidden" && /quota/i.test(error.message)) {
606
+ const account = await cloud.accountStatus(dataDir).catch(() => null);
607
+ if (account) {
608
+ const { quotaExceededMessage } = await import("./packages.js");
609
+ // The shared message is written for a screen. This is a terminal, so
610
+ // it names the command that shows the packages.
611
+ fail("forbidden", `${quotaExceededMessage(account)} Run \`sealkeep plans\` to see them.`);
612
+ }
613
+ }
614
+ throw error;
615
+ });
616
+ if (json) {
617
+ print(JSON.stringify(result, null, 2));
618
+ return;
619
+ }
620
+ print(`\n${result.durable ? "Stored" : "Uploaded"} ${result.objectKey} (${result.bytes} bytes)`);
621
+ if (result.note)
622
+ print(dim(` ${result.note}`));
623
+ print("");
624
+ return;
625
+ }
626
+ if (action === "archives") {
627
+ // Chunk families fold into one line per archive — the account stores
628
+ // objects, but a person asks about archives.
629
+ const { collapseCloudRows } = await import("./managed-chunks.js");
630
+ const rows = collapseCloudRows(await cloud.listCloudArchives(dataDir));
631
+ if (json) {
632
+ print(JSON.stringify(rows, null, 2));
633
+ return;
634
+ }
635
+ const durable = rows.filter((row) => row.state === "durable");
636
+ print(`\n${heading(`Managed storage (${durable.length} of ${rows.length} durable)`)}`);
637
+ print(table(rows, [
638
+ { header: "", get: (row) => (row.state === "durable" ? mark.ok() : dim("·")) },
639
+ { header: "ref", get: (row) => row.vault_ref },
640
+ { header: "archived", get: (row) => (row.durable_at ? relativeTime(row.durable_at) : dim(row.state)) },
641
+ { header: "size", get: (row) => bytes(Number(row.bytes) || 0), align: "right" }
642
+ ], "nothing stored yet"));
643
+ print(dim("\n Read one back with: sealkeep open <ref> <destination> --recovery-phrase …\n"));
644
+ return;
645
+ }
646
+ if (action === "pull") {
647
+ const [, vaultRef, destination] = positionals(args);
648
+ if (!vaultRef)
649
+ fail("invalid_argument", "Usage: sealkeep cloud pull <vault-ref> <destination>");
650
+ const out = required(destination, "Destination is required");
651
+ const { ciphertext, bytes: size } = await cloud.pullCiphertext(dataDir, vaultRef);
652
+ await writeFile(out, ciphertext, { mode: 0o600 });
653
+ if (json) {
654
+ print(JSON.stringify({ vaultRef, output: out, bytes: size }, null, 2));
655
+ return;
656
+ }
657
+ print(`\n ${mark.ok()} Pulled ${bold(bytes(size))} to ${out}`);
658
+ print(dim(" Decrypt it with: sealkeep recover <id> <destination> --recovery-phrase …\n"));
659
+ return;
660
+ }
661
+ fail("invalid_argument", `Unknown cloud action: ${action}. Try login, logout, status, archives, push, or pull.`);
662
+ }
663
+ if (command === "setup") {
664
+ // Refused before anything is created: a vault half-made over a typo'd flag
665
+ // would leave the phrase screen unshown, which is the one unrecoverable
666
+ // outcome this command has.
667
+ const password = take(args, "--password");
668
+ if (password !== undefined && (password.length < 8 || password.length > 1024)) {
669
+ fail("invalid_argument", "The vault password must be between 8 and 1024 characters.");
670
+ }
671
+ const result = await setupVault(dataDir, take(args, "--recovery-phrase"));
672
+ // Keep the phrase in this machine's keystore, the way the wizard does by
673
+ // default. Without it a vault made from the terminal looked finished and
674
+ // was not: the window's Archive button answered "this machine has no
675
+ // stored recovery phrase", and the background service could not seal a
676
+ // thing. Two setup paths that produce two different kinds of vault is a
677
+ // trap, so they now produce the same one. --no-remember opts out.
678
+ let remembered = null;
679
+ if (result.recoveryPhrase && !args.includes("--no-remember")) {
680
+ const { rememberRecoveryPhrase } = await import("./secrets.js");
681
+ try {
682
+ remembered = await rememberRecoveryPhrase(dataDir, result.vaultId, result.recoveryPhrase);
683
+ }
684
+ catch {
685
+ remembered = null;
686
+ }
687
+ }
688
+ // --password wraps the phrase (fresh, or this machine's kept copy for a
689
+ // vault that already exists) so `unlock` can trade a password for it later.
690
+ // A failed wrap must not fail setup: the phrase screen below still has to
691
+ // happen, because it is shown exactly once.
692
+ let passwordLock = false;
693
+ if (password) {
694
+ const phrase = result.recoveryPhrase ?? (await resolveRecoveryPhrase(dataDir, result.vaultId).catch(() => null));
695
+ if (phrase) {
696
+ try {
697
+ await wrapPhrase(dataDir, phrase, password);
698
+ passwordLock = true;
699
+ }
700
+ catch {
701
+ passwordLock = false;
702
+ }
703
+ }
704
+ }
705
+ if (json) {
706
+ print(JSON.stringify({ ...result, remembered, ...(password ? { passwordLock } : {}) }, null, 2));
707
+ return;
708
+ }
709
+ print(`\n${BRAND}\n`);
710
+ if (result.recoveryPhrase) {
711
+ print(recoveryPhraseScreen(result.recoveryPhrase));
712
+ await pauseUntilWrittenDown();
713
+ print(remembered
714
+ ? ` ${mark.ok()} This machine will unlock the vault on its own ${dim(`· phrase kept in ${remembered.backend}`)}`
715
+ : ` ${mark.warn()} This machine did not keep the phrase, so it can only archive when you type it. ${dim("Run `sealkeep autopilot` to store it.")}`);
716
+ }
717
+ else
718
+ print(` ${mark.ok()} Vault ${dim(result.vaultId.slice(0, 8))} was already set up.\n`);
719
+ if (password) {
720
+ print(passwordLock
721
+ ? ` ${mark.ok()} Your password unlocks this vault on this machine ${dim("· the recovery phrase still opens everything, everywhere")}`
722
+ : ` ${mark.warn()} No password was set${result.recoveryPhrase ? "" : " — this machine holds no phrase to wrap"}. The recovery phrase alone unlocks this vault.`);
723
+ }
724
+ const installs = await detectAgents();
725
+ print(heading("Detected agents"));
726
+ print(table(installs, [
727
+ { header: "", get: (install) => (install.detected ? mark.ok() : dim("·")) },
728
+ { header: "agent", get: (install) => install.agent },
729
+ { header: "location", get: (install) => (install.detected ? dim(shortPath(install.root)) : dim("not installed")) }
730
+ ], "none"));
731
+ print(heading("Next"));
732
+ print(steps([
733
+ `Find and queue existing sessions: ${cmd("sealkeep quickstart")}`,
734
+ `Or archive one file now: ${cmd("sealkeep archive <file> --recovery-phrase …")}`,
735
+ `Reviewable hook snippets written: ${dim(shortPath(result.files[0]))}`
736
+ ]));
737
+ print("");
738
+ return;
739
+ }
740
+ if (command === "init") {
741
+ const { config, phrase } = await initialize(dataDir, take(args, "--recovery-phrase"));
742
+ if (json) {
743
+ print(JSON.stringify({ vaultId: config.vaultId, recoveryPhrase: phrase }, null, 2));
744
+ return;
745
+ }
746
+ // No pause here: `init` is the machine-facing half of setup and is not in help.
747
+ print(`\n${BRAND}\n`, recoveryPhraseScreen(phrase), "");
748
+ return;
749
+ }
750
+ if (command === "status") {
751
+ const status = { ...(await vaultStatus(dataDir)), queue: await new ArchiveQueue(dataDir).stats() };
752
+ if (json) {
753
+ print(JSON.stringify(status, null, 2));
754
+ return;
755
+ }
756
+ const retention = await evaluateRetention(dataDir).catch(() => null);
757
+ const eligible = retention?.candidates.filter((candidate) => candidate.eligible).length ?? 0;
758
+ const live = liveness(await readHeartbeat(dataDir));
759
+ let managedAccount = null;
760
+ if (!status.remoteStorage) {
761
+ try {
762
+ const cloud = await import("./cloud.js");
763
+ managedAccount = await cloud.accountStatus(dataDir);
764
+ }
765
+ catch { /* not signed in */ }
766
+ }
767
+ print(`\n${BRAND} ${dim(`vault ${status.vaultId.slice(0, 8)}`)}\n`);
768
+ const rows = [
769
+ ["Background", live.state === "running" ? `${green("running")} ${dim(live.detail.replace(/^Running\. /, ""))}` : live.state === "late" ? `${amber("late")} ${dim(live.detail)}` : dim(live.detail)],
770
+ ["Archives", `${bold(String(status.archiveCount))} ${dim(`· ${bytes(status.archivedBytes)} of history preserved`)}`],
771
+ ["Queue", `${status.queue.ready} ready ${dim("·")} ${status.queue.leased} in flight ${dim("·")} ${status.queue.done} archived${status.queue.failed ? ` ${dim("·")} ${mark.fail()} ${status.queue.failed} failed` : ""}`],
772
+ ["Remote", status.remoteStorage
773
+ ? `${status.remoteStorage.provider}://${status.remoteStorage.bucket}/${status.remoteStorage.prefix}`
774
+ // A managed machine has no bucket of its own. Saying "none configured"
775
+ // to someone whose archives are in our storage is the same falsehood
776
+ // doctor was telling.
777
+ : managedAccount ? `${green("Sealkeep Cloud")} ${dim(`· ${managedAccount.email}`)}` : dim("none configured")],
778
+ ["Retention", retention ? `${retention.policy} ${dim("·")} ${eligible === 0 ? dim("nothing reclaimable") : `${bytes(retention.reclaimableBytes)} reclaimable`}` : dim("unknown")]
779
+ ];
780
+ if (managedAccount && managedAccount.quota_bytes > 0) {
781
+ const { quotaState, quotaMessage } = await import("./packages.js");
782
+ const state = quotaState(managedAccount.used_bytes, managedAccount.quota_bytes);
783
+ rows.splice(4, 0, ["Quota", `${quotaMarker(state.level)}${quotaMessage(state)}${state.level === "ok" ? "" : dim(" · sealkeep plans")}`]);
784
+ }
785
+ print(keyValue(rows));
786
+ print(`\n${hint(`${cmd("sealkeep doctor")} checks this machine end to end`)}\n`);
787
+ return;
788
+ }
789
+ if (command === "doctor") {
790
+ const report = await runDoctor(dataDir);
791
+ if (json) {
792
+ print(JSON.stringify(report, null, 2));
793
+ }
794
+ else {
795
+ print(`\n${BRAND} ${report.ok ? green("healthy") : mark.fail()}\n`);
796
+ print(table(report.checks, [
797
+ { header: "", get: (check) => statusMark(check.status) },
798
+ { header: "check", get: (check) => check.name },
799
+ { header: "detail", get: (check) => dim(check.detail) }
800
+ ], "no checks"));
801
+ print("");
802
+ }
803
+ if (!report.ok)
804
+ process.exitCode = 1;
805
+ return;
806
+ }
807
+ if (command === "archive") {
808
+ const source = required(positionals(args)[0], "Usage: sealkeep archive <file> --recovery-phrase <phrase>");
809
+ // Through unlock(), like every other command that needs the phrase: the
810
+ // keystore answers for a machine that remembered it, the vault password is
811
+ // traded for it at a terminal, and only a bare script with neither is told
812
+ // to pass the flag. Demanding the flag unconditionally — which this line
813
+ // used to do — made `archive` the one command a normal setup could not run.
814
+ const phraseForSeal = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
815
+ if (args.includes("--stream")) {
816
+ // Chunk → encrypt → send: the ciphertext never touches this disk. Needs
817
+ // a configured own bucket; the seal is verified in the provider's own
818
+ // checksum language before the record is written.
819
+ const { sealArchiveToCloud } = await import("./stream-to-cloud.js");
820
+ const fresh = args.includes("--fresh");
821
+ if (!fresh) {
822
+ // Say up front that an interrupted upload is being picked up, so a
823
+ // second long wait after a crash reads as progress, not a restart.
824
+ // The seal itself re-verifies the journal and may still decide to
825
+ // start over; this line reports what was found, never key material.
826
+ const { findSpoolForSource } = await import("./spool.js");
827
+ const journal = await findSpoolForSource(dataDir, source).catch(() => null);
828
+ if (journal && !json) {
829
+ const confirmed = journal.chunkHeaders.slice(0, journal.nextChunkIndex).reduce((sum, chunk) => sum + chunk.storedBytes, 0);
830
+ const totalChunks = Math.max(1, Math.ceil(journal.source.totalBytes / journal.source.chunkBytes));
831
+ print(` ↻ Resuming at chunk ${journal.nextChunkIndex} of ~${totalChunks} (${bytes(confirmed)} already confirmed in the bucket)`);
832
+ }
833
+ }
834
+ // The chunk-FOLDER layout is the default: one object per sealed chunk
835
+ // under project/date/session — simpler resume (HEAD, not protocol),
836
+ // per-chunk fetch, folder delete, and the envelope sidecar makes the
837
+ // bucket self-recovering. --single-object keeps the old multipart path.
838
+ if (!args.includes("--single-object")) {
839
+ const { sealToChunkFolder } = await import("./chunk-store.js");
840
+ const agentName = take(args, "--agent", "custom") ?? "custom";
841
+ const explicitTarget = take(args, "--target");
842
+ const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
843
+ const project = await (async () => {
844
+ try {
845
+ if (agentName === "claude")
846
+ return claudeProjectFromPath(source).project;
847
+ if (agentName === "codex")
848
+ return (await codexProjectFromRollout(source, new Date().toISOString())).project;
849
+ }
850
+ catch { /* no project is a folder called no-project, not an error */ }
851
+ return null;
852
+ })();
853
+ // Multi-target routing happens up here so a project pinned to Google
854
+ // Drive takes the single-object road (Drive holds whole objects), and
855
+ // everything else streams chunk folders to its routed target.
856
+ const { chooseTarget } = await import("./storage-targets.js");
857
+ const routedSize = await import("node:fs/promises").then(({ stat: statFile }) => statFile(source)).then((info) => info.size).catch(() => 0);
858
+ const routed = await chooseTarget(dataDir, { project, bytes: routedSize, targetId: explicitTarget ?? undefined });
859
+ if (routed.provider === "gdrive") {
860
+ const { archiveFile } = await import("./vault.js");
861
+ const { uploadArchive } = await import("./upload.js");
862
+ const record = await archiveFile(dataDir, source, phraseForSeal, agentName);
863
+ const uploaded = await uploadArchive(dataDir, record.id, {
864
+ remoteStorage: { provider: "gdrive", bucket: routed.bucket ?? "gdrive", prefix: routed.prefix ?? "vaultline", region: routed.region },
865
+ targetId: routed.id
866
+ });
867
+ if (json) {
868
+ print(JSON.stringify({ id: record.id, bytes: record.source.bytes, target: routed.id, provider: "gdrive", objectKey: uploaded.objectKey, streamed: false }, null, 2));
869
+ return;
870
+ }
871
+ print(` ${mark.ok()} Archived ${bold(bytes(record.source.bytes))} to Google Drive ${dim(`(target "${routed.id}", single object)`)}`);
872
+ return;
873
+ }
874
+ const outcome = await sealToChunkFolder(dataDir, source, phraseForSeal, agentName, { project, resume: !fresh, targetId: routed.id });
875
+ if (json) {
876
+ print(JSON.stringify({ id: outcome.record.id, bytes: outcome.record.source.bytes, storedBytes: outcome.storedBytes, folder: outcome.folder, chunks: outcome.chunkCount, reusedChunks: outcome.reusedChunks, indexed: outcome.indexed, streamed: true }, null, 2));
877
+ return;
878
+ }
879
+ print(` ${mark.ok()} Archived ${bold(bytes(outcome.record.source.bytes))} into ${outcome.record.remote?.provider}://…/${outcome.folder.split("/").slice(-3).join("/")}/ ${dim(`(${outcome.chunkCount} chunks)`)}`);
880
+ if (outcome.reusedChunks > 0)
881
+ print(` ${dim(`↻ resumed: ${outcome.reusedChunks} chunks were already in the bucket, verified instead of re-sent`)}`);
882
+ print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
883
+ return;
884
+ }
885
+ const outcome = await sealArchiveToCloud(dataDir, source, phraseForSeal, take(args, "--agent", "custom") ?? "custom", { resume: !fresh });
886
+ if (json) {
887
+ print(JSON.stringify({ id: outcome.record.id, bytes: outcome.record.source.bytes, storedBytes: outcome.storedBytes, objectKey: outcome.objectKey, streamed: true }, null, 2));
888
+ return;
889
+ }
890
+ print(` ${mark.ok()} Archived ${bold(bytes(outcome.record.source.bytes))} straight to ${outcome.record.remote?.provider} ${dim(`→ ${outcome.record.id}`)}`);
891
+ print(` ${dim(`${bytes(outcome.storedBytes)} sealed and verified in the bucket; this disk held at most ${bytes(outcome.heldAtMostBytes)} of it at any moment`)}`);
892
+ return;
893
+ }
894
+ const record = await archiveFile(dataDir, source, phraseForSeal, take(args, "--agent", "custom"));
895
+ if (json) {
896
+ print(JSON.stringify({ id: record.id, bytes: record.source.bytes, objectPath: record.objectPath, deduplicated: record.deduplicated }, null, 2));
897
+ return;
898
+ }
899
+ print(record.deduplicated
900
+ ? ` ${mark.ok()} Already archived, unchanged since ${relativeTime(record.createdAt)} ${dim(`(${record.id})`)}`
901
+ : ` ${mark.ok()} Archived ${bold(bytes(record.source.bytes))} ${dim(`→ ${record.id}`)}`);
902
+ return;
903
+ }
904
+ /**
905
+ * Pre-seal leak check. Deliberately needs no vault and no phrase: it reads
906
+ * the same plaintext a seal would, before anything is sealed. High findings
907
+ * set the exit code so scripts can gate on it; detection never leaves this
908
+ * machine — see leakscan.ts for why it cannot.
909
+ */
910
+ if (command === "scan") {
911
+ const { scanPath } = await import("./leakscan.js");
912
+ const target = required(positionals(args)[0], "Usage: sealkeep scan <file-or-directory> [--json]");
913
+ const report = await scanPath(target);
914
+ if (json) {
915
+ print(JSON.stringify(report, null, 2));
916
+ }
917
+ else if (report.files === 0) {
918
+ print(`\n ${mark.warn()} No .jsonl files at ${shortPath(target)}. Point scan at a transcript, or a directory of them.\n`);
919
+ }
920
+ else {
921
+ print(`\n${heading("Checking for pasted secrets")}`);
922
+ print(table(report.findings, [
923
+ { header: "", get: (finding) => (finding.severity === "high" ? mark.fail() : mark.warn()) },
924
+ { header: "kind", get: (finding) => finding.kind },
925
+ { header: "where", get: (finding) => dim(`${shortPath(finding.file, 40)}:${finding.line}`) },
926
+ { header: "preview", get: (finding) => finding.preview }
927
+ ], "Nothing that looks like a secret."));
928
+ const files = `${report.files} file${report.files === 1 ? "" : "s"}`;
929
+ print(report.findings.length
930
+ ? `\n ${report.high ? mark.fail() : mark.warn()} ${bold(String(report.findings.length))} finding${report.findings.length === 1 ? "" : "s"} in ${files} ${dim(`· ${report.high} high · ${report.medium} medium`)}\n ${dim("Previews are masked. Rotate anything real — sealing keeps it forever.")}\n`
931
+ : `\n ${mark.ok()} Scanned ${files}. Nothing that looks like a secret.\n`);
932
+ }
933
+ if (report.exitCode)
934
+ process.exitCode = 1;
935
+ return;
936
+ }
937
+ if (command === "list") {
938
+ const archives = await listArchives(dataDir);
939
+ if (json) {
940
+ print(JSON.stringify(archives, null, 2));
941
+ return;
942
+ }
943
+ print(`\n${heading(`Archives (${archives.length})`)}`);
944
+ print(table(archives, [
945
+ { header: "archived", get: (record) => relativeTime(record.createdAt) },
946
+ { header: "agent", get: (record) => record.source.agent },
947
+ { header: "size", get: (record) => bytes(record.source.bytes), align: "right" },
948
+ { header: "remote", get: (record) => (record.version === 2 && record.remote ? green("verified") : dim("local only")) },
949
+ { header: "source", get: (record) => dim(shortPath(record.source.path)) },
950
+ { header: "id", get: (record) => dim(record.id.slice(0, 8)) }
951
+ ], "Nothing archived yet. Run `sealkeep quickstart`."));
952
+ print("");
953
+ return;
954
+ }
955
+ if (command === "verify") {
956
+ const cloud = await import("./cloud.js");
957
+ const limit = take(args, "--limit");
958
+ if (!json)
959
+ print(`\n${heading("Checking every archive is really there")}`);
960
+ const checks = await cloud.verifyArchives(dataDir, {
961
+ limit: limit ? Number(limit) : undefined,
962
+ onProgress: json ? undefined : (check) => print(` ${check.ok ? mark.ok() : mark.warn()} ${check.vaultRef}${check.ok ? dim(` ${bytes(check.bytes)}`) : ` ${check.problem}`}`)
963
+ });
964
+ const broken = checks.filter((check) => !check.ok);
965
+ if (json) {
966
+ print(JSON.stringify({ checked: checks.length, broken: broken.length, checks }, null, 2));
967
+ }
968
+ else if (!checks.length)
969
+ print(dim(" Nothing stored yet.\n"));
970
+ else if (broken.length)
971
+ print(`\n ${mark.warn()} ${bold(String(broken.length))} of ${checks.length} could not be read back. Re-archive them.\n`);
972
+ else
973
+ print(`\n ${mark.ok()} All ${checks.length} archives fetched back and matched their recorded hash.\n`);
974
+ if (broken.length)
975
+ process.exitCode = 1;
976
+ return;
977
+ }
978
+ if (command === "plans") {
979
+ const { PACKAGES, packageForAccount } = await import("./packages.js");
980
+ let current = null;
981
+ try {
982
+ const cloud = await import("./cloud.js");
983
+ current = packageForAccount(await cloud.accountStatus(dataDir)).id;
984
+ }
985
+ catch { /* not signed in; just show the catalogue */ }
986
+ if (json) {
987
+ print(JSON.stringify({ current, packages: PACKAGES }, null, 2));
988
+ return;
989
+ }
990
+ print(`\n${heading("Storage packages")}`);
991
+ print(table([...PACKAGES], [
992
+ { header: "", get: (item) => (item.id === current ? mark.ok() : dim("·")) },
993
+ { header: "package", get: (item) => item.name },
994
+ { header: "storage", get: (item) => (item.managed ? `${item.gigabytes} GB` : dim("your own bucket")), align: "right" },
995
+ { header: "per month", get: (item) => (item.monthly ? `$${item.monthly}` : dim("free")), align: "right" }
996
+ ], "none"));
997
+ print(dim("\n On a package we run the storage and choose the provider. Your own bucket means\n your provider, your region, your bill. Either way we hold no key that opens an archive.\n"));
998
+ return;
999
+ }
1000
+ if (command === "open") {
1001
+ const [vaultRef, destination] = positionals(args);
1002
+ if (!vaultRef || !destination)
1003
+ fail("invalid_argument", "Usage: sealkeep open <vault-ref> <destination> --recovery-phrase <phrase>");
1004
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1005
+ const cloud = await import("./cloud.js");
1006
+ const outcome = await cloud.openFromCloud(dataDir, vaultRef, destination, phrase);
1007
+ if (json) {
1008
+ print(JSON.stringify(outcome, null, 2));
1009
+ return;
1010
+ }
1011
+ print(`\n ${mark.ok()} Opened ${bold(bytes(outcome.bytes))} from managed storage to ${outcome.output}\n`);
1012
+ return;
1013
+ }
1014
+ if (command === "recover") {
1015
+ const [rawTarget, destination] = positionals(args);
1016
+ let id = required(rawTarget, "Usage: sealkeep recover <id-or-session-path> [destination] --recovery-phrase <phrase> [--native] [--overwrite refuse|backup|replace]");
1017
+ let native = args.includes("--native");
1018
+ /**
1019
+ * The emergency this command exists for is not "I know the archive id" —
1020
+ * it is an agent's resume pointing at a file that is gone. So the target
1021
+ * may be the PATH (or just the filename) the agent complained about:
1022
+ * matched against archived sources, exact path first, then basename, and
1023
+ * restored NATIVELY by default — back to the exact spot the catalog
1024
+ * expects, so resume simply works again. Two different sessions matching
1025
+ * one fragment is a question, not a guess: both are listed with ids.
1026
+ */
1027
+ const looksLikeId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
1028
+ if (!looksLikeId) {
1029
+ const { resolve: resolvePath, basename } = await import("node:path");
1030
+ const archives = (await listArchives(dataDir)).filter((record) => record.version === 2);
1031
+ const wanted = id;
1032
+ const exact = archives.filter((record) => record.source.path === resolvePath(wanted));
1033
+ const byName = exact.length > 0 ? exact : archives.filter((record) => basename(record.source.path) === basename(wanted));
1034
+ const fuzzy = byName.length > 0 ? byName : archives.filter((record) => record.source.path.includes(wanted));
1035
+ if (fuzzy.length === 0) {
1036
+ fail("archive_not_found", `No archive covers a session matching "${wanted}". If the file was never archived, Sealkeep cannot bring it back — check \`sealkeep list\` for what it holds.`);
1037
+ }
1038
+ const paths = new Set(fuzzy.map((record) => record.source.path));
1039
+ if (paths.size > 1) {
1040
+ const lines = [...paths].slice(0, 6).map((path) => {
1041
+ const newest = fuzzy.filter((record) => record.source.path === path).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
1042
+ return ` ${newest.id} ${path}`;
1043
+ }).join("\n");
1044
+ fail("invalid_argument", `"${wanted}" matches ${paths.size} different sessions — say which:\n${lines}\n Then: sealkeep recover <id> --native`);
1045
+ }
1046
+ const newest = fuzzy.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
1047
+ id = newest.id;
1048
+ // Matched by the session's own path: the point is putting it BACK.
1049
+ if (!destination)
1050
+ native = true;
1051
+ if (!json)
1052
+ print(` ${dim(`Matched ${newest.source.path} → archive ${newest.id}${native ? " · restoring to its original location" : ""}`)}`);
1053
+ }
1054
+ const outcome = await restoreArchive(dataDir, id, required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required"), {
1055
+ destination: native ? undefined : required(destination, "Destination is required unless --native is used"),
1056
+ native, home: take(args, "--home"), overwrite: take(args, "--overwrite", "refuse")
1057
+ });
1058
+ if (json) {
1059
+ print(JSON.stringify({ id, output: outcome.output, bytes: outcome.bytes, native: outcome.native, backupPath: outcome.backupPath }, null, 2));
1060
+ return;
1061
+ }
1062
+ print(` ${mark.ok()} Recovered ${bold(bytes(outcome.bytes))} to ${outcome.output}${outcome.native ? dim(" (original location)") : ""}`);
1063
+ if (outcome.native)
1064
+ print(` ${dim("The agent's resume points here again — pick the session up where it left off.")}`);
1065
+ if (outcome.backupPath)
1066
+ print(` ${dim(`Previous file kept at ${outcome.backupPath}`)}`);
1067
+ return;
1068
+ }
1069
+ if (command === "agents") {
1070
+ const [action, agent] = positionals(args);
1071
+ if (action === "detect") {
1072
+ const installs = await detectAgents(take(args, "--home"));
1073
+ if (json) {
1074
+ print(JSON.stringify(installs, null, 2));
1075
+ return;
1076
+ }
1077
+ print(`\n${heading("Agents on this machine")}`);
1078
+ print(table(installs, [
1079
+ { header: "", get: (install) => (install.detected ? mark.ok() : dim("·")) },
1080
+ { header: "agent", get: (install) => install.agent },
1081
+ { header: "location", get: (install) => (install.detected ? dim(shortPath(install.root)) : dim("not installed")) }
1082
+ ], "none"));
1083
+ print("");
1084
+ return;
1085
+ }
1086
+ if (action === "sessions") {
1087
+ const found = await findTranscripts(agentId(agent), take(args, "--home"), Number(take(args, "--limit", "50")));
1088
+ if (json) {
1089
+ print(JSON.stringify(found, null, 2));
1090
+ return;
1091
+ }
1092
+ print(`\n${heading(`${agent} sessions (${found.length}, ${bytes(found.reduce((total, item) => total + item.bytes, 0))})`)}`);
1093
+ print(table(found, [
1094
+ { header: "modified", get: (item) => relativeTime(item.modifiedAt) },
1095
+ { header: "size", get: (item) => bytes(item.bytes), align: "right" },
1096
+ { header: "path", get: (item) => dim(shortPath(item.path, 60)) }
1097
+ ], "no sessions found"));
1098
+ print("");
1099
+ return;
1100
+ }
1101
+ if (action === "hook-config") {
1102
+ print(JSON.stringify(hookConfig(agentId(agent), take(args, "--executable", "vaultline"), dataDir), null, 2));
1103
+ return;
1104
+ }
1105
+ fail("invalid_argument", "Usage: sealkeep agents <detect|sessions|hook-config> [agent]");
1106
+ }
1107
+ if (command === "hook") {
1108
+ const [action] = positionals(args);
1109
+ if (action === "rehydrate") {
1110
+ // The SessionStart side of the hook pair: the agent is about to resume
1111
+ // a session — if its transcript left this disk for the vault, put it
1112
+ // back before the agent reads it. NEVER break the session: every
1113
+ // outcome, including "no phrase available here", is a JSON line and
1114
+ // exit 0.
1115
+ const { rehydrateSession, rehydrateTargetFromPayload } = await import("./rehydrate.js");
1116
+ const target = rehydrateTargetFromPayload(await readStdin());
1117
+ const phrase = await unlock(dataDir).catch(() => undefined);
1118
+ const outcome = await rehydrateSession(dataDir, target, phrase).catch((error) => ({
1119
+ rehydrated: false, reason: "restore-failed",
1120
+ note: error instanceof Error ? error.message.split("\n")[0] : "rehydrate failed"
1121
+ }));
1122
+ print(JSON.stringify(outcome));
1123
+ return;
1124
+ }
1125
+ if (action !== "enqueue" && action !== "archive")
1126
+ fail("invalid_argument", "Usage: sealkeep hook enqueue --agent <codex|claude> --data-dir <path> · sealkeep hook rehydrate --agent <codex|claude> --data-dir <path>");
1127
+ const agent = agentId(take(args, "--agent"));
1128
+ // The hook records intent only: no encryption, no secret, no network. `sealkeep queue run` does the work.
1129
+ const event = await hookEventFromStdin(agent, await readStdin());
1130
+ const { job, deduped } = await new ArchiveQueue(dataDir).enqueue({ sourcePath: event.sourcePath, agent, event: event.event, sessionId: event.sessionId });
1131
+ print(JSON.stringify({ queued: true, deduped, jobId: job.id, event: job.event, note: action === "archive" ? "`hook archive` now queues work; run `sealkeep queue run` to encrypt it" : undefined }));
1132
+ return;
1133
+ }
1134
+ if (command === "queue") {
1135
+ const [action, jobId] = positionals(args);
1136
+ const queue = new ArchiveQueue(dataDir);
1137
+ if (action === "list" || action === undefined) {
1138
+ const status = take(args, "--status");
1139
+ const jobs = await queue.list(status ? { status } : undefined);
1140
+ if (json) {
1141
+ print(JSON.stringify(jobs, null, 2));
1142
+ return;
1143
+ }
1144
+ print(`\n${heading(`Queue (${jobs.length})`)}`);
1145
+ print(table(jobs, [
1146
+ { header: "", get: (job) => statusMark(job.status) },
1147
+ { header: "status", get: (job) => job.status },
1148
+ { header: "agent", get: (job) => job.agent },
1149
+ { header: "event", get: (job) => dim(job.event) },
1150
+ { header: "tries", get: (job) => String(job.attempts), align: "right" },
1151
+ { header: "source", get: (job) => dim(shortPath(job.sourcePath)) },
1152
+ { header: "note", get: (job) => (job.lastError ? mark.warn() + " " + dim(job.lastError.code) : "") }
1153
+ ], "Nothing queued."));
1154
+ print("");
1155
+ return;
1156
+ }
1157
+ if (action === "run") {
1158
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Pass --recovery-phrase, set VAULTLINE_RECOVERY_PHRASE, or run `sealkeep autopilot` so this machine remembers it.");
1159
+ const processed = await drainQueue(dataDir, phrase, { max: Number(take(args, "--max", "25")) });
1160
+ if (json) {
1161
+ print(JSON.stringify(processed, null, 2));
1162
+ return;
1163
+ }
1164
+ const done = processed.filter((job) => job.status === "done");
1165
+ const failed = processed.filter((job) => job.status !== "done");
1166
+ const archived = done.reduce((total, job) => total + (job.result?.bytes ?? 0), 0);
1167
+ print(processed.length === 0
1168
+ ? ` ${dim("Nothing waiting.")}`
1169
+ : ` ${mark.ok()} Encrypted ${bold(String(done.length))} session${done.length === 1 ? "" : "s"} ${dim(`· ${bytes(archived)} of history preserved`)}`);
1170
+ for (const job of failed)
1171
+ print(` ${mark.warn()} ${shortPath(job.sourcePath)} ${dim(`— ${job.lastError?.message ?? "will retry"}`)}`);
1172
+ return;
1173
+ }
1174
+ if (action === "retry") {
1175
+ const job = await queue.retry(required(jobId, "Usage: sealkeep queue retry <job-id>"));
1176
+ if (json) {
1177
+ print(JSON.stringify(job, null, 2));
1178
+ return;
1179
+ }
1180
+ print(` ${mark.ok()} Job ${dim(job.id.slice(0, 8))} queued again.`);
1181
+ return;
1182
+ }
1183
+ fail("invalid_argument", "Usage: sealkeep queue <list|run|retry> [options]");
1184
+ }
1185
+ // `desktop` did this job on a fixed port before the window had a name. It is
1186
+ // kept dispatching so nobody's muscle memory breaks, and dropped from help so
1187
+ // there is one advertised way in.
1188
+ /**
1189
+ * The memory side of the product: an agent asking Sealkeep what it did last
1190
+ * time, rather than a human running `search`.
1191
+ *
1192
+ * `mcp` runs the server over stdio, which is how every agent starts one.
1193
+ * `mcp install` registers it, because a server nothing points at is a server
1194
+ * nobody uses — the state this shipped in. Worse, `onboarding.ts` has been
1195
+ * writing a config fragment that runs `sealkeep mcp`, a command that did not
1196
+ * exist until now: anyone who merged it got an agent that failed to start.
1197
+ */
1198
+ if (command === "mcp") {
1199
+ const [action] = positionals(args);
1200
+ if (action === "install") {
1201
+ const { installMcpServer } = await import("./mcp-install.js");
1202
+ const only = take(args, "--agent");
1203
+ const result = await installMcpServer(dataDir, { only, exec: undefined });
1204
+ if (json) {
1205
+ print(JSON.stringify(result, null, 2));
1206
+ return;
1207
+ }
1208
+ print("");
1209
+ for (const entry of result.agents) {
1210
+ if (entry.installed)
1211
+ print(` ${mark.ok()} ${entry.label} — ${entry.detail}`);
1212
+ else
1213
+ print(` ${mark.warn()} ${entry.label} — ${entry.detail}`);
1214
+ }
1215
+ // Only claimed when something was actually registered. Printing it
1216
+ // unconditionally is the same defect this whole command was written to
1217
+ // stop: a cheerful line that is false for the person reading it.
1218
+ const registered = result.agents.filter((entry) => entry.installed).length;
1219
+ if (registered > 0)
1220
+ print(dim("\n Ask one of those agents to search your history and it will find these sessions.\n"));
1221
+ else if (result.agents.length > 0)
1222
+ print(dim("\n Nothing was registered, so no agent can search your history yet.\n"));
1223
+ else
1224
+ print(dim("\n No agents found on this machine.\n"));
1225
+ return;
1226
+ }
1227
+ if (action && action !== "run")
1228
+ fail("invalid_argument", "Usage: sealkeep mcp [run|install] [--agent <name>]");
1229
+ // No banner, no colour: stdout is the protocol channel here, and anything
1230
+ // printed to it corrupts the first message the client reads.
1231
+ await import("./mcp.js");
1232
+ return;
1233
+ }
1234
+ if (command === "ui" || command === "desktop") {
1235
+ // `sealkeep ui hostname` prints the one line that turns the address bar
1236
+ // into something readable. It is printed rather than applied because
1237
+ // writing /etc/hosts needs a password, and a tool that archives transcripts
1238
+ // has no business asking for one.
1239
+ if (positionals(args)[0] === "hostname") {
1240
+ const port = (await readUiRecord(dataDir))?.port ?? DEFAULT_UI_PORT;
1241
+ print(`\n${heading("A nicer address")}`);
1242
+ print(` Sealkeep already answers to ${bold("vault.line")}; what is missing is your`);
1243
+ print(` machine knowing where that is. One line does it:\n`);
1244
+ print(` ${cmd(`echo "127.0.0.1 vault.line" | sudo tee -a /etc/hosts`)}\n`);
1245
+ print(` Then the vault lives at ${bold(`http://vault.line:${port}`)}`);
1246
+ print(dim(` Undo it by deleting that line from /etc/hosts. Nothing else changes:`));
1247
+ print(dim(` the server is bound to 127.0.0.1 either way, and ${DISPLAY_HOST}:${port} keeps working.\n`));
1248
+ return;
1249
+ }
1250
+ const requested = take(args, "--port");
1251
+ // A stable port, because the address bar is part of the product: a URL that
1252
+ // changes every run cannot be bookmarked, read aloud, or recognised. Two
1253
+ // vaults on one machine still work — the second finds 7477 busy and falls
1254
+ // back to a free port rather than refusing to start.
1255
+ // --no-browser: scripts and sandboxes start the server without a window
1256
+ // popping over whatever the person is doing. The flag was accepted and
1257
+ // silently ignored for a while, which is worse than not existing.
1258
+ const handle = await startUi(dataDir, { port: requested ? await freePort(Number(requested)) : undefined, openBrowser: !args.includes("--no-browser") });
1259
+ if (json) {
1260
+ // The token is left out on purpose — see printUi. A script that needs it
1261
+ // reads the token file, which is the same file the browser gate names.
1262
+ print(JSON.stringify({ origin: handle.origin, port: handle.port, reused: handle.reused, browserOpened: handle.browser.opened, tokenFile: localApiTokenPath(dataDir) }, null, 2));
1263
+ }
1264
+ else
1265
+ printUi(handle, dataDir);
1266
+ if (handle.reused)
1267
+ return;
1268
+ holdUntilStopped(handle);
1269
+ return;
1270
+ }
1271
+ if (command === "api") {
1272
+ // Headless and on a predictable port: this is the address the TUI, the MCP
1273
+ // server and anything else local is pointed at, so it neither opens a
1274
+ // window nor inherits an ephemeral port from a UI that happens to be up.
1275
+ const handle = await startUi(dataDir, { port: await freePort(Number(take(args, "--port", "4180"))), openBrowser: false, reuse: false });
1276
+ print(`\n${BRAND} local API on ${bold(handle.origin.replace("http://", ""))}`);
1277
+ print(` ${dim(`Token file (do not share): ${shortPath(localApiTokenPath(dataDir), 60)}`)}`);
1278
+ print(` ${dim("Ctrl-C to stop.")}\n`);
1279
+ holdUntilStopped(handle);
1280
+ return;
1281
+ }
1282
+ if (command === "tui") {
1283
+ await runTui(dataDir, { base: take(args, "--api"), token: take(args, "--token") });
1284
+ return;
1285
+ }
1286
+ if (command === "update") {
1287
+ const [action] = positionals(args);
1288
+ if (action !== "verify")
1289
+ fail("invalid_argument", "Usage: sealkeep update verify --manifest <path> [--artifact <path>] [--key <public-key.pem>]");
1290
+ const manifestPath = required(take(args, "--manifest"), "--manifest is required");
1291
+ const signed = JSON.parse(await readFile(manifestPath, "utf8"));
1292
+ const keyPath = take(args, "--key");
1293
+ const config = await readConfig(dataDir).catch(() => null);
1294
+ const trusted = keyPath ? { [signed.keyId]: await readFile(keyPath, "utf8") } : config?.updateKeys ?? {};
1295
+ if (Object.keys(trusted).length === 0)
1296
+ fail("forbidden", "No trusted release keys. Pass --key, or record one under `updateKeys` in the vault config.");
1297
+ const manifest = verifyManifest(signed, trusted);
1298
+ assertNotDowngrade(manifest.release, take(args, "--current", "0.1.0"));
1299
+ const artifact = take(args, "--artifact");
1300
+ if (artifact) {
1301
+ const entry = manifest.artifacts.find((item) => artifact.endsWith(item.name));
1302
+ if (!entry)
1303
+ fail("invalid_argument", `The manifest does not describe ${artifact}`);
1304
+ await verifyArtifact(artifact, entry);
1305
+ }
1306
+ if (json) {
1307
+ print(JSON.stringify({ verified: true, release: manifest.release, keyId: signed.keyId }, null, 2));
1308
+ return;
1309
+ }
1310
+ print(` ${mark.ok()} Release ${bold(manifest.release)} verified ${dim(`(signed by ${signed.keyId})`)}${artifact ? `\n ${mark.ok()} Artifact checksum matches` : ""}`);
1311
+ return;
1312
+ }
1313
+ if (command === "upload") {
1314
+ const [archiveId] = positionals(args);
1315
+ const endpoint = take(args, "--endpoint");
1316
+ const parsed = endpoint ? new URL(endpoint) : undefined;
1317
+ const overrides = parsed ? { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined, protocol: parsed.protocol.replace(":", ""), pathStyle: true } : undefined;
1318
+ const options = { overrides, storageConfigId: take(args, "--config-id") };
1319
+ const result = archiveId && !args.includes("--all") ? await uploadArchive(dataDir, archiveId, options) : await uploadPending(dataDir, options);
1320
+ if (json) {
1321
+ print(JSON.stringify(result, null, 2));
1322
+ return;
1323
+ }
1324
+ if ("uploaded" in result) {
1325
+ print(` ${mark.ok()} Uploaded and verified ${bold(String(result.uploaded.length))} archive${result.uploaded.length === 1 ? "" : "s"}${result.skipped ? dim(` · ${result.skipped} already durable`) : ""}`);
1326
+ for (const failure of result.failed)
1327
+ print(` ${mark.warn()} ${dim(failure.archiveId.slice(0, 8))} ${dim(failure.error)}`);
1328
+ }
1329
+ else {
1330
+ print(` ${mark.ok()} ${bold(bytes(result.bytes))} uploaded and verified ${dim(`→ ${result.objectKey}`)}`);
1331
+ }
1332
+ return;
1333
+ }
1334
+ if (command === "search") {
1335
+ const [query] = positionals(args);
1336
+ const mode = args.includes("--content") ? "content" : "metadata";
1337
+ const hits = await search(dataDir, required(query, "Usage: sealkeep search <query> [--content] [--agent <name>]"), {
1338
+ mode, agent: take(args, "--agent"),
1339
+ phrase: mode === "content" ? required(await unlock(dataDir, take(args, "--recovery-phrase")), "Content search decrypts locally and needs --recovery-phrase") : undefined
1340
+ });
1341
+ if (json) {
1342
+ print(JSON.stringify({ mode, hits }, null, 2));
1343
+ return;
1344
+ }
1345
+ print(`\n${heading(`${hits.length} result${hits.length === 1 ? "" : "s"} ${dim(`· ${mode} search`)}`)}`);
1346
+ if (hits.length === 0) {
1347
+ print(` ${dim(mode === "metadata" ? "Nothing matched. Metadata search covers paths, agents, and dates — try --content to search inside sessions." : "Nothing matched inside your sessions.")}\n`);
1348
+ return;
1349
+ }
1350
+ for (const hit of hits) {
1351
+ print(` ${bold(shortPath(hit.path, 60))}`);
1352
+ print(` ${dim(`${hit.agent} · ${bytes(hit.bytes)} · ${relativeTime(hit.createdAt)} · ${hit.id.slice(0, 8)}`)}`);
1353
+ if (hit.matched === "content")
1354
+ for (const snippet of hit.snippets)
1355
+ print(` ${blue("│")} ${dim(snippet.trim().slice(0, 96))}`);
1356
+ print("");
1357
+ }
1358
+ return;
1359
+ }
1360
+ if (command === "index") {
1361
+ const [action] = positionals(args);
1362
+ const phrase = async () => required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1363
+ if (action === "build") {
1364
+ const built = await buildContentIndex(dataDir, await phrase());
1365
+ if (json) {
1366
+ print(JSON.stringify(built, null, 2));
1367
+ return;
1368
+ }
1369
+ print(` ${mark.ok()} Indexed ${bold(String(built.archives))} archives ${dim(`(${built.tokens} terms)`)}`);
1370
+ print(` ${dim("The index is encrypted at rest and never uploaded. Remove it with `sealkeep index drop`.")}`);
1371
+ return;
1372
+ }
1373
+ if (action === "drop") {
1374
+ await dropContentIndex(dataDir);
1375
+ print(` ${mark.ok()} Content index removed.`);
1376
+ return;
1377
+ }
1378
+ if (action === "status") {
1379
+ const { indexCoverage } = await import("./search.js");
1380
+ const coverage = await indexCoverage(dataDir);
1381
+ if (json) {
1382
+ print(JSON.stringify(coverage, null, 2));
1383
+ return;
1384
+ }
1385
+ print(`\n ${bold(String(coverage.indexed))} of ${bold(String(coverage.total))} archives searchable ${dim(`· index ${bytes(coverage.indexBytes)}${coverage.builtAt ? ` · updated ${coverage.builtAt.slice(0, 16).replace("T", " ")}` : ""}`)}`);
1386
+ if (coverage.missing.length > 0) {
1387
+ print(` ${dim(`${coverage.missing.length} not yet indexed — new seals index themselves; \`sealkeep index build\` adds the rest:`)}`);
1388
+ for (const item of coverage.missing.slice(0, 5))
1389
+ print(` ${dim("·")} ${item.path.split("/").pop() ?? item.id} ${dim(item.agent)}`);
1390
+ if (coverage.missing.length > 5)
1391
+ print(` ${dim(`… and ${coverage.missing.length - 5} more`)}`);
1392
+ }
1393
+ print("");
1394
+ return;
1395
+ }
1396
+ fail("invalid_argument", "Usage: sealkeep index <build|status|drop> --recovery-phrase <phrase>");
1397
+ }
1398
+ if (command === "audit") {
1399
+ const [action] = positionals(args);
1400
+ const events = await readAudit(dataDir, { limit: Number(take(args, "--limit", "200")) });
1401
+ if (action === "export") {
1402
+ print(toCsv(events));
1403
+ return;
1404
+ }
1405
+ if (json) {
1406
+ print(JSON.stringify(events, null, 2));
1407
+ return;
1408
+ }
1409
+ print(`\n${heading(`Activity (${events.length})`)}`);
1410
+ print(table(events, [
1411
+ { header: "when", get: (event) => relativeTime(event.at) },
1412
+ { header: "", get: (event) => (event.outcome === "allowed" ? mark.ok() : mark.fail()) },
1413
+ { header: "action", get: (event) => event.action },
1414
+ { header: "detail", get: (event) => dim(Object.entries(event.detail ?? {}).slice(0, 3).map(([key, value]) => `${key}=${String(value).slice(0, 28)}`).join(" ")) }
1415
+ ], "Nothing has happened yet."));
1416
+ print(`\n${hint(`${cmd("sealkeep audit export")} writes CSV`)}\n`);
1417
+ return;
1418
+ }
1419
+ if (command === "recovery") {
1420
+ const [action] = positionals(args);
1421
+ const config = await readConfig(dataDir);
1422
+ if (action === "kit") {
1423
+ // The kit is read after the machine is gone, so it must name the cloud
1424
+ // account when there is one.
1425
+ let managed = null;
1426
+ try {
1427
+ const cloud = await import("./cloud.js");
1428
+ const account = await cloud.accountStatus(dataDir);
1429
+ if (account?.email)
1430
+ managed = { email: account.email };
1431
+ }
1432
+ catch { /* not signed in; the kit says "this machine only" */ }
1433
+ const sheet = recoveryKit(config, { managed });
1434
+ const target = take(args, "--out");
1435
+ if (target) {
1436
+ const { writeFile } = await import("node:fs/promises");
1437
+ await writeFile(target, sheet, { mode: 0o600 });
1438
+ print(` ${mark.ok()} Recovery kit written to ${target}`);
1439
+ print(` ${dim("Print it, fill in the words by hand, and keep it away from this machine.")}`);
1440
+ return;
1441
+ }
1442
+ print(sheet);
1443
+ return;
1444
+ }
1445
+ if (action === "verify") {
1446
+ const typed = take(args, "--recovery-phrase") ?? await promptForPhrase(" Type your recovery phrase (input hidden): ");
1447
+ const result = await verifyAgainstVault(dataDir, typed);
1448
+ if (json) {
1449
+ print(JSON.stringify(result, null, 2));
1450
+ return;
1451
+ }
1452
+ if (result.valid) {
1453
+ print(`\n ${mark.ok()} That opens this vault. Your written copy is correct.\n`);
1454
+ for (const note of result.problems)
1455
+ print(` ${dim(note)}`);
1456
+ return;
1457
+ }
1458
+ print(`\n ${mark.fail()} That does not open this vault.`);
1459
+ for (const problem of result.problems)
1460
+ print(` ${dim("·")} ${problem}`);
1461
+ print(`\n ${dim("Nothing was changed. Check your kit and try again.")}\n`);
1462
+ process.exitCode = 1;
1463
+ return;
1464
+ }
1465
+ if (action === "seal") {
1466
+ // The account panel warns that losing this machine loses every archive on
1467
+ // it, and tells people to run exactly this. It did not exist, so the
1468
+ // warning had no remedy behind it.
1469
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1470
+ const cloud = await import("./cloud.js");
1471
+ const { readConfig, addRecipient } = await import("./vault.js");
1472
+ const { generateDeviceKeys } = await import("./start.js");
1473
+ const config = await readConfig(dataDir);
1474
+ // This used to seal `{vaultId, sealedAt}` — an id and a timestamp — while
1475
+ // printing that the vault's key was stored. A device private key is never
1476
+ // written to disk, so by the time this command runs there is no existing
1477
+ // key to seal: the honest way to keep the promise is to mint one, register
1478
+ // its public half so future archives can be opened with it, and escrow the
1479
+ // private half under the phrase.
1480
+ const keys = generateDeviceKeys();
1481
+ await addRecipient(dataDir, "sealed key copy", keys.publicKeyBase64);
1482
+ await cloud.pushEscrow(dataDir, { vaultId: config.vaultId, devicePrivateKey: keys.privateKeyBase64, createdAt: new Date().toISOString() }, phrase);
1483
+ if (json) {
1484
+ print(JSON.stringify({ sealed: true, vaultId: config.vaultId }, null, 2));
1485
+ return;
1486
+ }
1487
+ print(`\n ${mark.ok()} A sealed copy of this vault's key is stored with your account.`);
1488
+ print(dim(" It is sealed with your recovery phrase, which we never receive. Losing"));
1489
+ print(dim(" the phrase still loses the archives — that is the point.\n"));
1490
+ return;
1491
+ }
1492
+ if (action === "open") {
1493
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required");
1494
+ const cloud = await import("./cloud.js");
1495
+ const material = await cloud.pullEscrow(dataDir, phrase);
1496
+ if (json) {
1497
+ print(JSON.stringify(material, null, 2));
1498
+ return;
1499
+ }
1500
+ print(`\n ${mark.ok()} Opened the sealed copy stored with your account.`);
1501
+ for (const [key, value] of Object.entries(material))
1502
+ print(` ${key}: ${String(value)}`);
1503
+ print("");
1504
+ return;
1505
+ }
1506
+ fail("invalid_argument", "Usage: sealkeep recovery <kit|verify|seal|open> [--out <path>]");
1507
+ }
1508
+ if (command === "phrase" && positionals(args)[0] === "rotate") {
1509
+ const { rotateVaultPhrase, rotatedPhrasePath } = await import("./rotate.js");
1510
+ const { hasPasswordLock } = await import("./password-lock.js");
1511
+ const current = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or let the keystore answer)");
1512
+ const password = (await hasPasswordLock(dataDir))
1513
+ ? (take(args, "--password") ?? await promptForPhrase("Vault password: "))
1514
+ : undefined;
1515
+ const out = take(args, "--out") ?? rotatedPhrasePath(dataDir);
1516
+ const { report } = await rotateVaultPhrase(dataDir, { currentPhrase: current, password, phraseFile: out });
1517
+ if (json) {
1518
+ print(JSON.stringify(report, null, 2));
1519
+ return;
1520
+ }
1521
+ // The new words are deliberately NOT printed: a phrase in a terminal lands
1522
+ // in scrollback, transcripts and logs — which is how the last one burned.
1523
+ print(`\n${BRAND} phrase rotated`);
1524
+ print(` ${mark.ok()} ${bold(String(report.rewrapped))} archives now open with the new phrase ${dim("(the old one still opens them locally — retire it once you hold the new words)")}`);
1525
+ print(` ${mark.ok()} The new phrase is in ${bold(out)} ${dim("— read it, write it down, then delete the file.")}`);
1526
+ if (report.passwordLockRewrapped)
1527
+ print(` ${mark.ok()} Your password now opens the new phrase.`);
1528
+ if (report.staleCloudCopies > 0)
1529
+ print(` ${mark.warn()} ${report.staleCloudCopies} cloud ${report.staleCloudCopies === 1 ? "copy" : "copies"} still carry the old wraps until re-pushed — anyone holding the OLD phrase and those objects can still open them.`);
1530
+ if (report.skipped.length > 0)
1531
+ print(` ${mark.warn()} ${report.skipped.length} skipped: ${report.skipped[0].reason}`);
1532
+ print("");
1533
+ return;
1534
+ }
1535
+ if (command === "share") {
1536
+ const [sub, a, b] = positionals(args);
1537
+ const { createShareBundle, openShareBundle } = await import("./share.js");
1538
+ if (sub === "open") {
1539
+ const passcode = take(args, "--passcode") ?? await promptForPhrase("Share passcode: ");
1540
+ const opened = await openShareBundle(required(a, "Usage: sealkeep share open <bundle> <destination> --passcode <words>"), required(b, "Usage: sealkeep share open <bundle> <destination>"), passcode);
1541
+ print(`\n${BRAND} ${mark.ok()} Opened ${bold(bytes(opened.bytes))} to ${b} ${dim("— no vault needed; the passcode was the key.")}\n`);
1542
+ return;
1543
+ }
1544
+ const archiveId = required(sub, "Usage: sealkeep share <archive-id> --out <bundle> [--passcode <words>]");
1545
+ const vaultPhrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or let the keystore answer)");
1546
+ const out = take(args, "--out") ?? `${archiveId.slice(0, 8)}.vlshare`;
1547
+ const result = await createShareBundle(dataDir, archiveId, out, { vaultPhrase, passcode: take(args, "--passcode") });
1548
+ print(`\n${BRAND} share bundle written`);
1549
+ print(` ${mark.ok()} ${bold(bytes(result.bytes))} → ${result.bundlePath}`);
1550
+ print(` ${mark.ok()} Passcode: ${bold(result.passcode)}`);
1551
+ print(` ${dim("Send the bundle over anything — without the passcode it is noise. Send the passcode another way.")}`);
1552
+ print(` ${dim("They open it with:")} ${cmd(`sealkeep share open ${result.bundlePath} <destination>`)}`);
1553
+ print(` ${dim("Your vault phrase does NOT open the bundle; only this passcode does.")}\n`);
1554
+ return;
1555
+ }
1556
+ if (command === "password" && positionals(args)[0] === "change") {
1557
+ const { changeVaultPassword } = await import("./rotate.js");
1558
+ const current = take(args, "--current") ?? await promptForPhrase("Current password: ");
1559
+ const fresh = take(args, "--new") ?? await promptForPhrase("New password (12+ characters): ");
1560
+ await changeVaultPassword(dataDir, current, fresh);
1561
+ print(`\n${BRAND} password changed`);
1562
+ print(` ${mark.ok()} The vault opens with the new password from now on. The recovery phrase is unchanged.\n`);
1563
+ return;
1564
+ }
1565
+ if (command === "migrate") {
1566
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set VAULTLINE_RECOVERY_PHRASE)");
1567
+ const result = await migrateVault(dataDir, phrase);
1568
+ if (json) {
1569
+ print(JSON.stringify(result, null, 2));
1570
+ return;
1571
+ }
1572
+ print(` ${mark.ok()} ${result.migrated} archive${result.migrated === 1 ? "" : "s"} upgraded to the current format${result.alreadyCurrent ? dim(` · ${result.alreadyCurrent} already current`) : ""}`);
1573
+ return;
1574
+ }
1575
+ if (command === "rewrap") {
1576
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "--recovery-phrase is required (or set VAULTLINE_RECOVERY_PHRASE)");
1577
+ const result = await rewrapVault(dataDir, phrase, { group: take(args, "--group") });
1578
+ if (json) {
1579
+ print(JSON.stringify(result, null, 2));
1580
+ return;
1581
+ }
1582
+ print(` ${mark.ok()} ${result.rewrapped} archive${result.rewrapped === 1 ? "" : "s"} now open to ${bold(String(result.recipients))} recipient${result.recipients === 1 ? "" : "s"}`);
1583
+ for (const skip of result.skipped)
1584
+ print(` ${mark.warn()} ${dim(skip.id.slice(0, 8))} ${dim(skip.reason)}`);
1585
+ return;
1586
+ }
1587
+ if (command === "recipients") {
1588
+ const [action, value] = positionals(args);
1589
+ if (action === "list" || action === undefined) {
1590
+ const recipients = (await readConfig(dataDir)).recipients ?? [];
1591
+ if (json) {
1592
+ print(JSON.stringify(recipients, null, 2));
1593
+ return;
1594
+ }
1595
+ print(`\n${heading(`Recipients (${recipients.length + 1})`)}`);
1596
+ print(` ${green("always")} ${dim("your recovery phrase")}`);
1597
+ print(table(recipients, [
1598
+ { header: "added", get: (recipient) => relativeTime(recipient.addedAt) },
1599
+ { header: "label", get: (recipient) => recipient.label },
1600
+ { header: "group", get: (recipient) => dim(recipient.group ?? "—") },
1601
+ { header: "id", get: (recipient) => dim(recipient.id.slice(0, 12)) }
1602
+ ], "No device keys registered."));
1603
+ print("");
1604
+ return;
1605
+ }
1606
+ if (action === "add") {
1607
+ const config = await addRecipient(dataDir, required(take(args, "--label"), "--label is required"), required(take(args, "--public-key"), "--public-key is required (base64 X25519)"), take(args, "--group"));
1608
+ if (json) {
1609
+ print(JSON.stringify(config.recipients, null, 2));
1610
+ return;
1611
+ }
1612
+ print(` ${mark.ok()} Added. New archives include it.`);
1613
+ print(` ${hint(`${cmd("sealkeep rewrap")} extends access to archives you already have`)}`);
1614
+ return;
1615
+ }
1616
+ if (action === "remove") {
1617
+ await removeRecipient(dataDir, required(value, "Usage: sealkeep recipients remove <recipient-id>"));
1618
+ print(` ${mark.ok()} Removed from future archives.`);
1619
+ print(` ${hint(`${cmd("sealkeep rewrap")} revokes access to archives you already have`)}`);
1620
+ return;
1621
+ }
1622
+ fail("invalid_argument", "Usage: sealkeep recipients <list|add|remove> [options]");
1623
+ }
1624
+ if (command === "device") {
1625
+ const [action] = positionals(args);
1626
+ if (action !== "keygen")
1627
+ fail("invalid_argument", "Usage: sealkeep device keygen");
1628
+ const pair = generateKeyPairSync("x25519");
1629
+ const privateRaw = pair.privateKey.export({ type: "pkcs8", format: "der" }).subarray(-32);
1630
+ const publicKey = rawPublicKey(pair.publicKey).toString("base64");
1631
+ if (json) {
1632
+ print(JSON.stringify({ publicKey, privateKey: privateRaw.toString("base64"), recipientId: keyRecipientId(rawPublicKey(pair.publicKey)) }, null, 2));
1633
+ return;
1634
+ }
1635
+ print(`\n${callout("Device private key — store it offline now", [bold(privateRaw.toString("base64")), "", dim("Shown once. This device can open archives without the phrase.")])}\n`);
1636
+ print(`${heading("Register the public half")}`);
1637
+ print(` ${cmd(`sealkeep recipients add --label "<device name>" --public-key ${publicKey}`)}\n`);
1638
+ return;
1639
+ }
1640
+ if (command === "storage") {
1641
+ const [action, archiveId] = positionals(args);
1642
+ if (action === "targets") {
1643
+ const sub = positionals(args)[1];
1644
+ const { resolveTargets, targetUsage, setStorageTargets } = await import("./storage-targets.js");
1645
+ if (sub === "add") {
1646
+ const provider = required(take(args, "--provider"), "--provider is required");
1647
+ const id = take(args, "--id") ?? provider;
1648
+ const existing = (await readConfig(dataDir)).storageTargets ?? await resolveTargets(dataDir);
1649
+ const bucketFlag = take(args, "--bucket");
1650
+ const target = {
1651
+ id, provider,
1652
+ // A Drive target's "bucket" is the visible folder in the Drive; it
1653
+ // defaults to the product name so a rebrand renames NEW folders in
1654
+ // one place (src/branding.ts).
1655
+ ...(bucketFlag ? { bucket: bucketFlag } : provider === "gdrive" ? { bucket: DRIVE_FOLDER_DEFAULT } : {}),
1656
+ ...(take(args, "--prefix") ? { prefix: take(args, "--prefix") } : {}),
1657
+ ...(take(args, "--region") ? { region: take(args, "--region") } : {}),
1658
+ ...(take(args, "--max-gb") ? { maxGb: Number(take(args, "--max-gb")) } : {}),
1659
+ ...(take(args, "--projects") ? { projects: take(args, "--projects").split(",").map((p) => p.trim()).filter(Boolean) } : {}),
1660
+ ...(take(args, "--priority") ? { priority: Number(take(args, "--priority")) } : {})
1661
+ };
1662
+ await setStorageTargets(dataDir, [...existing.filter((t) => t.id !== id), target]);
1663
+ print(` ${mark.ok()} Target ${bold(id)} saved.`);
1664
+ return;
1665
+ }
1666
+ if (sub === "remove") {
1667
+ const id = required(positionals(args)[2], "Usage: sealkeep storage targets remove <id>");
1668
+ const existing = (await readConfig(dataDir)).storageTargets ?? [];
1669
+ await setStorageTargets(dataDir, existing.filter((t) => t.id !== id));
1670
+ print(` ${mark.ok()} Target ${bold(id)} removed. Archives already there stay readable — records remember where they live.`);
1671
+ return;
1672
+ }
1673
+ const targets = await resolveTargets(dataDir);
1674
+ const usage = await targetUsage(dataDir, targets);
1675
+ if (json) {
1676
+ print(JSON.stringify(targets.map((t) => ({ ...t, usedBytes: usage.get(t.id) ?? 0 })), null, 2));
1677
+ return;
1678
+ }
1679
+ if (targets.length === 0) {
1680
+ print(` ${dim("No storage targets. Configure one, or sign in to Sealkeep Cloud.")}`);
1681
+ return;
1682
+ }
1683
+ print("");
1684
+ for (const t of targets) {
1685
+ const used = usage.get(t.id) ?? 0;
1686
+ const cap = t.maxGb !== undefined ? ` of ${t.maxGb} GB` : "";
1687
+ const pins = t.projects?.length ? ` · projects: ${t.projects.join(", ")}` : "";
1688
+ print(` ${bold(t.id.padEnd(12))} ${t.provider}${t.bucket ? `://${t.bucket}` : ""} ${dim(`· ${bytes(used)}${cap}${pins} · priority ${t.priority ?? (t.provider === "vaultline" ? 0 : 10)}`)}`);
1689
+ }
1690
+ print(` ${dim("New seals go to the pinned project target first, then lowest priority with room. Reads always follow each archive's own record.")}\n`);
1691
+ return;
1692
+ }
1693
+ if (action === "show" || action === undefined) {
1694
+ const remote = (await readConfig(dataDir)).remoteStorage;
1695
+ if (json) {
1696
+ print(JSON.stringify(remote ?? null, null, 2));
1697
+ return;
1698
+ }
1699
+ print(remote
1700
+ ? ` ${bold(`${remote.provider}://${remote.bucket}/${remote.prefix}`)}${remote.region ? dim(` · ${remote.region}`) : ""}`
1701
+ : ` ${dim("No remote target. Set one with `sealkeep storage configure --provider r2 --bucket … --prefix …`")}`);
1702
+ return;
1703
+ }
1704
+ if (action === "configure") {
1705
+ const provider = required(take(args, "--provider"), "--provider is required");
1706
+ if (!Object.hasOwn(providers, provider))
1707
+ fail("provider_unsupported", "--provider must be vaultline, s3, r2, or gcs");
1708
+ const config = await configureRemoteStorage(dataDir, { provider, bucket: required(take(args, "--bucket"), "--bucket is required"), prefix: required(take(args, "--prefix"), "--prefix is required"), region: take(args, "--region") });
1709
+ if (json) {
1710
+ print(JSON.stringify(config.remoteStorage, null, 2));
1711
+ return;
1712
+ }
1713
+ print(` ${mark.ok()} Target saved: ${bold(`${config.remoteStorage.provider}://${config.remoteStorage.bucket}/${config.remoteStorage.prefix}`)}`);
1714
+ print(` ${hint("Store credentials next: echo '{\"accessKeyId\":\"…\",\"secretAccessKey\":\"…\"}' | sealkeep storage credentials set")}`);
1715
+ return;
1716
+ }
1717
+ if (action === "setup") {
1718
+ const provider = required(take(args, "--provider"), "--provider is required (s3, r2, or gcs)");
1719
+ const config = await readConfig(dataDir).catch(() => null);
1720
+ const plan = setupPlan({
1721
+ provider,
1722
+ bucket: required(take(args, "--bucket") ?? config?.remoteStorage?.bucket, "--bucket is required"),
1723
+ prefix: take(args, "--prefix") ?? config?.remoteStorage?.prefix ?? `vaultline/${config?.vaultId?.slice(0, 8) ?? "me"}`,
1724
+ region: take(args, "--region"), accountId: take(args, "--account-id"), project: take(args, "--project")
1725
+ });
1726
+ if (json) {
1727
+ print(JSON.stringify(plan, null, 2));
1728
+ return;
1729
+ }
1730
+ print(`\n${BRAND} ${dim(`${plan.provider} setup for ${plan.bucket}/${plan.prefix}`)}`);
1731
+ print(`${dim(" Nothing here runs automatically. Read each step, then paste it yourself.")}`);
1732
+ for (const [index, step] of plan.steps.entries()) {
1733
+ print(`\n${blue(String(index + 1))} ${bold(step.title)}`);
1734
+ print(` ${dim(step.why)}`);
1735
+ if (step.link)
1736
+ print(` ${dim("open:")} ${step.link}`);
1737
+ for (const line of step.commands ?? [])
1738
+ print(`\n ${line.split("\n").join("\n ")}`);
1739
+ if (step.document) {
1740
+ print(`\n ${dim(`save as ${step.document.name}:`)}`);
1741
+ print(step.document.body.split("\n").map((line) => ` ${dim("│")} ${line}`).join("\n"));
1742
+ }
1743
+ }
1744
+ print(`\n${heading("Then tell Sealkeep about it")}`);
1745
+ for (const line of plan.finish)
1746
+ print(` ${line.split("\n").join("\n ")}`);
1747
+ for (const note of plan.notes)
1748
+ print(`\n ${dim("note:")} ${dim(note)}`);
1749
+ print("");
1750
+ return;
1751
+ }
1752
+ if (action === "connect") {
1753
+ // `archiveId` is just the second positional here: the provider to connect.
1754
+ if (archiveId !== "gdrive")
1755
+ fail("invalid_argument", "Usage: sealkeep storage connect gdrive [--config-id <id>] [--backend <name>]");
1756
+ const configId = take(args, "--config-id") ?? (await readConfig(dataDir)).vaultId;
1757
+ const backend = take(args, "--backend");
1758
+ // The account's shared Google client first: no console visit, no env
1759
+ // var — the consent screen is the whole setup. The PKCE desktop path
1760
+ // stays for self-hosters who set VAULTLINE_GDRIVE_CLIENT_ID.
1761
+ const viaCloud = !process.env.VAULTLINE_GDRIVE_CLIENT_ID;
1762
+ const credentials = viaCloud ? await (await import("./cloud.js")).connectGdriveViaCloud(dataDir, {
1763
+ onConsentUrl: (url) => {
1764
+ print(`\n ${dim("Approve Sealkeep in the browser window. If none opened, use this link:")}`);
1765
+ print(` ${url}`);
1766
+ print(`\n ${dim("The request is for drive.file only: Google lets Sealkeep see files it created — sealed ciphertext — and nothing else in your Drive.")}`);
1767
+ print(` ${dim("Waiting for the browser…")}`);
1768
+ }
1769
+ }) : await connectGdrive({
1770
+ onConsentUrl: (url) => {
1771
+ print(`\n ${dim("Approve Sealkeep in the browser window. If none opened, use this link:")}`);
1772
+ print(` ${url}`);
1773
+ print(`\n ${dim("The request is for drive.file only: Google lets Sealkeep see files it created — sealed ciphertext — and nothing else in your Drive.")}`);
1774
+ print(` ${dim("Waiting for the browser…")}`);
1775
+ }
1776
+ });
1777
+ const stored = await storeProviderCredentials(dataDir, configId, credentials, backend);
1778
+ if (json) {
1779
+ print(JSON.stringify({ provider: "gdrive", backend: stored.backend, configId }, null, 2));
1780
+ return;
1781
+ }
1782
+ print(`\n ${mark.ok()} Google Drive connected ${dim(`· refresh token stored in ${stored.backend} for ${configId.slice(0, 8)}`)}`);
1783
+ print(` ${hint("Google only ever receives ciphertext: archives are sealed on this machine before anything leaves it.")}`);
1784
+ return;
1785
+ }
1786
+ if (action === "credentials") {
1787
+ const [, mode] = positionals(args);
1788
+ const configId = take(args, "--config-id") ?? (await readConfig(dataDir)).vaultId;
1789
+ const backend = take(args, "--backend");
1790
+ if (mode === "set") {
1791
+ // Read the credential from stdin: an argument would be visible in the process list.
1792
+ const raw = required((await readStdin()).trim(), "Pipe provider credentials as JSON on stdin, for example: echo '{\"accessKeyId\":\"…\",\"secretAccessKey\":\"…\"}' | sealkeep storage credentials set");
1793
+ let parsed;
1794
+ try {
1795
+ parsed = JSON.parse(raw);
1796
+ }
1797
+ catch {
1798
+ fail("invalid_argument", "Credentials on stdin must be JSON");
1799
+ }
1800
+ const stored = await storeProviderCredentials(dataDir, configId, parsed, backend);
1801
+ print(` ${mark.ok()} Stored in ${bold(stored.backend)} ${dim(`for ${configId.slice(0, 8)}`)}`);
1802
+ return;
1803
+ }
1804
+ if (mode === "delete") {
1805
+ const removed = await deleteProviderCredentials(dataDir, configId, backend);
1806
+ print(` ${mark.ok()} Removed from ${removed.backend}.`);
1807
+ return;
1808
+ }
1809
+ if (mode === "check") {
1810
+ const credentials = await loadProviderCredentials(dataDir, configId, backend);
1811
+ print(` ${mark.ok()} Credentials present ${dim(`(${"accessKeyId" in credentials ? "access key" : "service account"})`)}`);
1812
+ return;
1813
+ }
1814
+ fail("invalid_argument", "Usage: sealkeep storage credentials <set|check|delete> [--config-id <id>] [--backend <name>]");
1815
+ }
1816
+ if (action === "lease") {
1817
+ const config = await readConfig(dataDir);
1818
+ if (!config.remoteStorage)
1819
+ fail("storage_not_configured", "No remote storage configured. Run: sealkeep storage configure ...");
1820
+ const record = (await listArchives(dataDir)).find((item) => item.id === required(archiveId, "Usage: sealkeep storage lease <archive-id>"));
1821
+ if (!record)
1822
+ fail("archive_not_found", `Archive not found: ${archiveId}`);
1823
+ print(JSON.stringify(providers[config.remoteStorage.provider].createUploadLease(config.remoteStorage, { archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.source.bytes }), null, 2));
1824
+ return;
1825
+ }
1826
+ fail("invalid_argument", "Usage: sealkeep storage <show|configure|setup|connect|credentials|lease> [options]");
1827
+ }
1828
+ if (command === "retention") {
1829
+ const [action, ...rest] = positionals(args);
1830
+ const olderThanDays = take(args, "--older-than-days");
1831
+ const options = olderThanDays === undefined ? {} : { olderThanDays: Number(olderThanDays) };
1832
+ if (action === "preview" || action === undefined) {
1833
+ const evaluation = await evaluateRetention(dataDir, options);
1834
+ if (json) {
1835
+ print(JSON.stringify(evaluation, null, 2));
1836
+ return;
1837
+ }
1838
+ const eligible = evaluation.candidates.filter((candidate) => candidate.eligible);
1839
+ print(`\n${heading("Retention")}`);
1840
+ print(keyValue([
1841
+ ["Policy", `${bold(evaluation.policy)} ${dim(`· older than ${evaluation.olderThanDays}d · ${evaluation.graceDays}d grace`)}`],
1842
+ ["Eligible", eligible.length === 0 ? dim("nothing — every source is still protected") : `${bold(String(eligible.length))} sources ${dim(`· ${bytes(evaluation.reclaimableBytes)} would be freed`)}`]
1843
+ ]));
1844
+ print("");
1845
+ print(table(evaluation.candidates, [
1846
+ { header: "", get: (candidate) => (candidate.eligible ? green("free") : dim("held")) },
1847
+ { header: "size", get: (candidate) => bytes(candidate.bytes), align: "right" },
1848
+ { header: "source", get: (candidate) => dim(shortPath(candidate.path)) },
1849
+ { header: "why not", get: (candidate) => dim(candidate.blockers[0] ?? "") }
1850
+ ], "No local sources are being tracked."));
1851
+ print("");
1852
+ return;
1853
+ }
1854
+ if (action === "offload") {
1855
+ // Reclaiming a source is only half of freeing a disk: the sealed archive
1856
+ // stays, and on real transcripts that is about two thirds of what it
1857
+ // replaced. This removes local archives the bucket has a verified copy of.
1858
+ const { offloadArchives } = await import("./offload.js");
1859
+ const result = await offloadArchives(dataDir, { confirm: args.includes("--confirm") });
1860
+ if (json) {
1861
+ print(JSON.stringify(result, null, 2));
1862
+ return;
1863
+ }
1864
+ if (result.mode === "preview") {
1865
+ if (result.wouldOffload.length === 0) {
1866
+ print(` ${dim("No archive has a verified remote copy this build can read back.")}`);
1867
+ for (const item of result.blocked.slice(0, 3))
1868
+ print(` ${dim(`${item.id.slice(0, 8)}: ${item.reason}`)}`);
1869
+ return;
1870
+ }
1871
+ print(`\n ${bold(bytes(result.freedBytes))} held locally by ${result.wouldOffload.length} archive${result.wouldOffload.length === 1 ? "" : "s"} already in your bucket`);
1872
+ print(dim(" Removing them frees that space. Restoring downloads the archive back,"));
1873
+ print(dim(" so they stop being readable while offline — that is the trade.\n"));
1874
+ print(` ${hint(`${cmd("sealkeep retention offload --confirm")} removes them`)}\n`);
1875
+ return;
1876
+ }
1877
+ print(` ${mark.ok()} Offloaded ${bold(String(result.offloaded.length))} archive${result.offloaded.length === 1 ? "" : "s"}, freeing ${bold(bytes(result.freedBytes))}`);
1878
+ if (result.failed.length)
1879
+ print(` ${dim(`${result.failed.length} kept: ${result.failed[0].error}`)}`);
1880
+ return;
1881
+ }
1882
+ if (action === "prune") {
1883
+ // Snapshots of a session that was still growing. Each is standalone, so
1884
+ // the earlier ones are duplication once a newer one exists — and until
1885
+ // now nothing ever noticed, so they were kept, and paid for, forever.
1886
+ const result = await pruneRedundantArchives(dataDir, { confirm: args.includes("--confirm") });
1887
+ if (json) {
1888
+ print(JSON.stringify(result, null, 2));
1889
+ return;
1890
+ }
1891
+ if (result.mode === "preview") {
1892
+ if (result.wouldRemove.length === 0) {
1893
+ print(` ${dim("No archive is a duplicate of a newer one.")}`);
1894
+ return;
1895
+ }
1896
+ print(`\n ${bold(bytes(result.freedBytes))} in ${result.wouldRemove.length} superseded snapshot${result.wouldRemove.length === 1 ? "" : "s"}`);
1897
+ print(table(result.wouldRemove, [
1898
+ { header: "size", get: (item) => bytes(item.storedBytes), align: "right" },
1899
+ { header: "source", get: (item) => dim(shortPath(item.path)) },
1900
+ { header: "replaced by", get: (item) => dim(item.supersededBy.slice(0, 8)) }
1901
+ ], ""));
1902
+ print(` ${hint(`${cmd("sealkeep retention prune --confirm")} removes them`)}\n`);
1903
+ return;
1904
+ }
1905
+ print(` ${mark.ok()} Removed ${bold(String(result.removed.length))} superseded snapshot${result.removed.length === 1 ? "" : "s"}, freeing ${bold(bytes(result.freedBytes))}`);
1906
+ if (result.failed.length)
1907
+ print(` ${dim(`${result.failed.length} could not be removed`)}`);
1908
+ return;
1909
+ }
1910
+ if (action === "apply") {
1911
+ // Without --confirm this is a dry run, and a dry run changes nothing on disk.
1912
+ const result = await applyRetention(dataDir, { ...options, confirm: args.includes("--confirm") });
1913
+ if (json) {
1914
+ print(JSON.stringify(result, null, 2));
1915
+ return;
1916
+ }
1917
+ if (result.mode === "dry-run") {
1918
+ if (result.wouldReclaim === 0) {
1919
+ print(` ${dim("Nothing is eligible. Every source is still protected.")}`);
1920
+ // The evaluation collects every reason deliberately, and this used to
1921
+ // throw them all away — leaving "still protected" with no way to act
1922
+ // on it. Show the distinct reasons, so the answer to "why is nothing
1923
+ // being reclaimed" is on screen instead of in --json.
1924
+ const reasons = new Map();
1925
+ for (const candidate of result.candidates) {
1926
+ for (const blocker of candidate.blockers)
1927
+ reasons.set(blocker, (reasons.get(blocker) ?? 0) + 1);
1928
+ }
1929
+ for (const [reason, count] of [...reasons].sort((a, b) => b[1] - a[1]).slice(0, 6)) {
1930
+ print(` ${dim("·")} ${reason}${count > 1 ? dim(` (${count} sources)`) : ""}`);
1931
+ }
1932
+ return;
1933
+ }
1934
+ print(` ${bold(String(result.wouldReclaim))} source${result.wouldReclaim === 1 ? "" : "s"} would move to the trash, freeing ${bold(bytes(result.wouldFreeBytes))}.\n ${hint(`re-run with ${cmd("--confirm")} to do it`)}`);
1935
+ return;
1936
+ }
1937
+ print(` ${mark.ok()} Moved ${bold(String(result.reclaimed.length))} source${result.reclaimed.length === 1 ? "" : "s"} to the trash, freeing ${bold(bytes(result.freedBytes))}`);
1938
+ print(` ${dim("They are recoverable from the trash, and their archives are untouched.")}`);
1939
+ for (const failure of result.failed)
1940
+ print(` ${mark.warn()} ${dim(failure.id.slice(0, 8))} ${dim(failure.error)}`);
1941
+ return;
1942
+ }
1943
+ if (action === "approve") {
1944
+ if (rest.length === 0)
1945
+ fail("invalid_argument", "Usage: sealkeep retention approve <archive-id> [...]");
1946
+ const approved = await approveReclamation(dataDir, rest);
1947
+ print(` ${mark.ok()} ${rest.length} approved ${dim(`· ${approved.length} total`)}`);
1948
+ return;
1949
+ }
1950
+ if (action === "policy") {
1951
+ const policy = take(args, "--policy");
1952
+ const graceDays = take(args, "--grace-days");
1953
+ // "off" rather than an omitted flag, so turning a rule back off is a thing
1954
+ // you can type. Both accept it; both default to off.
1955
+ const idleDays = take(args, "--idle-days");
1956
+ const minBytes = take(args, "--min-bytes");
1957
+ const nullable = (value) => (value === undefined ? undefined : value === "off" || value === "null" ? null : Number(value));
1958
+ const describe = (settings) => [
1959
+ `older than ${settings.olderThanDays}d`,
1960
+ `${settings.graceDays}d grace`,
1961
+ settings.sourceIdleDays === null ? "any idle time" : `untouched ${settings.sourceIdleDays}d`,
1962
+ settings.minSourceBytes === null ? "any size" : `over ${bytes(settings.minSourceBytes)}`
1963
+ ].join(" · ");
1964
+ if (!policy && olderThanDays === undefined && graceDays === undefined && idleDays === undefined && minBytes === undefined) {
1965
+ const settings = retentionSettings(await readConfig(dataDir));
1966
+ if (json) {
1967
+ print(JSON.stringify(settings, null, 2));
1968
+ return;
1969
+ }
1970
+ print(` ${bold(settings.policy)} ${dim(`· ${describe(settings)}`)}`);
1971
+ return;
1972
+ }
1973
+ const config = await setRetentionPolicy(dataDir, {
1974
+ ...(policy ? { policy } : {}), ...options,
1975
+ ...(graceDays === undefined ? {} : { graceDays: Number(graceDays) }),
1976
+ ...(idleDays === undefined ? {} : { sourceIdleDays: nullable(idleDays) }),
1977
+ ...(minBytes === undefined ? {} : { minSourceBytes: nullable(minBytes) })
1978
+ });
1979
+ print(` ${mark.ok()} Policy set to ${bold(config.retention.policy)} ${dim(`· ${describe(retentionSettings(config))}`)}`);
1980
+ return;
1981
+ }
1982
+ fail("invalid_argument", "Usage: sealkeep retention <preview|apply|prune|offload|approve|policy> [options]");
1983
+ }
1984
+ if (command === "daemon") {
1985
+ const phrase = required(await unlock(dataDir, take(args, "--recovery-phrase")), "No recovery phrase available. Run `sealkeep autopilot` so this machine can unlock itself, or set VAULTLINE_RECOVERY_PHRASE.");
1986
+ const reclaim = args.includes("--reclaim");
1987
+ const daemon = await startDaemon(dataDir, {
1988
+ phrase, intervalMs: Number(take(args, "--interval", "30")) * 1000, home: take(args, "--home"),
1989
+ watch: !args.includes("--no-watch"), upload: !args.includes("--no-upload"), reclaim,
1990
+ notifications: args.includes("--quiet") ? false : undefined,
1991
+ onTick: (result) => {
1992
+ if (!(result.archived || result.uploaded || result.reclaimed || result.failed))
1993
+ return;
1994
+ const parts = [result.archived && `${result.archived} archived`, result.uploaded && `${result.uploaded} uploaded`, result.reclaimed && `${result.reclaimed} reclaimed`, result.failed && `${result.failed} failed`].filter(Boolean);
1995
+ print(` ${dim(new Date().toLocaleTimeString())} ${parts.join(dim(" · "))}`);
1996
+ }
1997
+ });
1998
+ const status = await daemon.status();
1999
+ print(`\n${BRAND} ${green("watching")}`);
2000
+ print(keyValue([
2001
+ ["Roots", status.watching.length ? status.watching.map((root) => shortPath(root, 50)).join("\n ") : dim("none found")],
2002
+ ["Reclaim", reclaim ? "enabled" : dim("disabled")]
2003
+ ]));
2004
+ print(` ${dim("Ctrl-C to stop.")}\n`);
2005
+ for (const signal of ["SIGINT", "SIGTERM"])
2006
+ process.on(signal, () => { void daemon.close().then(() => process.exit(0)); });
2007
+ await daemon.tick();
2008
+ return;
2009
+ }
2010
+ print(`${mark.fail()} Unknown command: ${command}\n${hint(`run ${cmd("sealkeep help")}`)}`);
2011
+ process.exitCode = 1;
2012
+ }
2013
+ const HINTS = {
2014
+ vault_not_initialized: "run `sealkeep quickstart`",
2015
+ recovery_phrase_missing: "pass --recovery-phrase, or set VAULTLINE_RECOVERY_PHRASE",
2016
+ recovery_phrase_mismatch: "check the phrase from your recovery kit",
2017
+ storage_not_configured: "run `sealkeep storage configure --provider … --bucket … --prefix …`",
2018
+ signer_not_configured: "set VAULTLINE_ENABLE_SIGNER=1 and store credentials",
2019
+ destination_exists: "add --overwrite backup to keep the existing file",
2020
+ destination_unwritable: "free some space, or restore to a different <destination> on another volume",
2021
+ archive_not_found: "run `sealkeep list` to see archive ids",
2022
+ // The scariest error the product can print. The refusal is the protection —
2023
+ // nothing was written — and only this local sealed file is damaged; a stored
2024
+ // copy, if one exists, is unaffected and `sealkeep verify` proves it.
2025
+ 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"
2026
+ };
2027
+ main().catch((error) => {
2028
+ const { error: payload } = errorPayload(error);
2029
+ console.error(`\n ${mark.fail()} ${payload.message}`);
2030
+ const suggestion = HINTS[payload.code];
2031
+ if (suggestion)
2032
+ console.error(` ${hint(suggestion)}`);
2033
+ console.error(` ${dim(payload.code)}\n`);
2034
+ process.exitCode = 1;
2035
+ });