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,85 @@
1
+ import type { TranscriptCandidate } from "./adapters.js";
2
+ /**
3
+ * What leaves this machine.
4
+ *
5
+ * The rules are set in the account panel and enforced here, on the machine,
6
+ * before anything is queued — so a session the customer excluded is never read,
7
+ * never sealed, and never uploaded. Deciding this server-side would be theatre:
8
+ * by the time the server could refuse an upload, the bytes would already be in
9
+ * flight.
10
+ *
11
+ * The last fetched copy is cached beside the vault so autopilot keeps honouring
12
+ * the customer's choice while the control plane is unreachable. Falling back to
13
+ * "upload everything" when we cannot read the rules would upload exactly what
14
+ * someone chose to hold back, so the cache is the answer rather than a default.
15
+ */
16
+ export type SyncMode = "all" | "recent" | "settled";
17
+ export type SyncRules = {
18
+ mode: SyncMode;
19
+ keepLast: number;
20
+ minAgeDays: number;
21
+ };
22
+ export declare const DEFAULT_SYNC_RULES: SyncRules;
23
+ /**
24
+ * The whole cached answer, not just the rules.
25
+ *
26
+ * The account's reply already carries plan, email and quota alongside the sync
27
+ * fields, and for a long time those were thrown away — so the dashboard asked
28
+ * the control plane again (a second identical request per settings read), and a
29
+ * machine that went offline forgot its owner was a paying customer: the plan
30
+ * badge vanished and every plan-gated surface degraded to the free look. One
31
+ * response, one file, everything it said.
32
+ */
33
+ export type AccountSnapshot = {
34
+ plan: "free" | "pro_lite" | "pro" | null;
35
+ email: string | null;
36
+ quota: {
37
+ usedBytes: number;
38
+ quotaBytes: number;
39
+ } | null;
40
+ /** When the control plane last actually answered — this file's write time, in the data. */
41
+ fetchedAt: string | null;
42
+ };
43
+ export declare function cacheSyncRules(dataDir: string, raw: unknown): Promise<SyncRules>;
44
+ export declare function cachedSyncRules(dataDir: string): Promise<SyncRules | null>;
45
+ /** The last account answer this machine ever received; null on a vault that never had one. */
46
+ export declare function cachedAccount(dataDir: string): Promise<AccountSnapshot | null>;
47
+ /** How long the last account answer is served as-is before asking again. */
48
+ export declare const ACCOUNT_TTL_MS: number;
49
+ /**
50
+ * The account, remembered if recent, live if stale, remembered again if the
51
+ * control plane is unreachable.
52
+ *
53
+ * `source: "account"` means this is the account's answer — just now, or within
54
+ * the TTL; `"cache"` is an old answer being served because nothing fresher can
55
+ * be had (with `fetchedAt` saying how old); `"none"` is a vault that has never
56
+ * been linked. The TTL matters more than it looks: asking live on every read
57
+ * put a ~4-second network round trip inside GET /v1/status, which every page's
58
+ * identity card and plan bar sat waiting on.
59
+ */
60
+ export declare function resolveAccount(dataDir: string): Promise<{
61
+ account: AccountSnapshot;
62
+ source: "account" | "cache" | "none";
63
+ }>;
64
+ /**
65
+ * Reads the rules from the account, caching them; falls back to the last cached
66
+ * copy when the control plane cannot be reached, and only to "everything" when
67
+ * this machine has never successfully read them.
68
+ */
69
+ export declare function resolveSyncRules(dataDir: string): Promise<{
70
+ rules: SyncRules;
71
+ source: "account" | "cache" | "default";
72
+ }>;
73
+ /**
74
+ * Applies the rules to what was found on disk.
75
+ *
76
+ * `recent` keeps the newest N by modification time. `settled` keeps only what
77
+ * has not been touched for `minAgeDays`, so work still in progress stays put.
78
+ * Both are computed against `now` rather than read from the clock directly, so
79
+ * the behaviour is testable.
80
+ */
81
+ export declare function applySyncRules(found: readonly TranscriptCandidate[], rules: SyncRules, now?: number): {
82
+ included: TranscriptCandidate[];
83
+ heldBack: TranscriptCandidate[];
84
+ };
85
+ export declare function describeSyncRules(rules: SyncRules): string;
@@ -0,0 +1,125 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const DEFAULT_SYNC_RULES = { mode: "all", keepLast: 20, minAgeDays: 7 };
4
+ const CACHE = "sync-rules.json";
5
+ function coerce(raw) {
6
+ const mode = raw?.sync_mode === "recent" || raw?.sync_mode === "settled" ? raw.sync_mode : "all";
7
+ const keepLast = Number(raw?.sync_keep_last);
8
+ const minAgeDays = Number(raw?.sync_min_age_days);
9
+ return {
10
+ mode,
11
+ keepLast: Number.isFinite(keepLast) && keepLast > 0 ? Math.floor(keepLast) : DEFAULT_SYNC_RULES.keepLast,
12
+ minAgeDays: Number.isFinite(minAgeDays) && minAgeDays > 0 ? minAgeDays : DEFAULT_SYNC_RULES.minAgeDays
13
+ };
14
+ }
15
+ /** Reads both shapes: the control plane's snake_case reply, and this file's own saved snapshot. */
16
+ function coerceAccount(raw) {
17
+ const plan = raw?.plan === "pro" || raw?.plan === "pro_lite" || raw?.plan === "free" ? raw.plan : null;
18
+ const used = Number(raw?.quota?.usedBytes ?? raw?.used_bytes);
19
+ const limit = Number(raw?.quota?.quotaBytes ?? raw?.quota_bytes);
20
+ return {
21
+ plan,
22
+ email: typeof raw?.email === "string" && raw.email.includes("@") ? raw.email : null,
23
+ quota: Number.isFinite(used) && Number.isFinite(limit) ? { usedBytes: used, quotaBytes: limit } : null,
24
+ fetchedAt: typeof raw?.fetchedAt === "string" ? raw.fetchedAt : null
25
+ };
26
+ }
27
+ export async function cacheSyncRules(dataDir, raw) {
28
+ const rules = coerce(raw);
29
+ const account = coerceAccount(raw);
30
+ await writeFile(join(dataDir, CACHE), JSON.stringify({ ...rules, ...account, fetchedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
31
+ return rules;
32
+ }
33
+ export async function cachedSyncRules(dataDir) {
34
+ try {
35
+ const saved = JSON.parse(await readFile(join(dataDir, CACHE), "utf8"));
36
+ return coerce({ sync_mode: saved.mode, sync_keep_last: saved.keepLast, sync_min_age_days: saved.minAgeDays });
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** The last account answer this machine ever received; null on a vault that never had one. */
43
+ export async function cachedAccount(dataDir) {
44
+ try {
45
+ const saved = JSON.parse(await readFile(join(dataDir, CACHE), "utf8"));
46
+ const account = coerceAccount(saved);
47
+ // A pre-snapshot cache file (rules only) has no account half; report null
48
+ // rather than an all-null snapshot pretending the account said nothing.
49
+ return account.plan === null && account.email === null && account.quota === null ? null : account;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** How long the last account answer is served as-is before asking again. */
56
+ export const ACCOUNT_TTL_MS = 5 * 60_000;
57
+ /**
58
+ * The account, remembered if recent, live if stale, remembered again if the
59
+ * control plane is unreachable.
60
+ *
61
+ * `source: "account"` means this is the account's answer — just now, or within
62
+ * the TTL; `"cache"` is an old answer being served because nothing fresher can
63
+ * be had (with `fetchedAt` saying how old); `"none"` is a vault that has never
64
+ * been linked. The TTL matters more than it looks: asking live on every read
65
+ * put a ~4-second network round trip inside GET /v1/status, which every page's
66
+ * identity card and plan bar sat waiting on.
67
+ */
68
+ export async function resolveAccount(dataDir) {
69
+ const recent = await cachedAccount(dataDir);
70
+ if (recent?.fetchedAt && Date.now() - Date.parse(recent.fetchedAt) < ACCOUNT_TTL_MS) {
71
+ return { account: recent, source: "account" };
72
+ }
73
+ try {
74
+ const { accountStatus } = await import("./cloud.js");
75
+ const raw = await accountStatus(dataDir);
76
+ await cacheSyncRules(dataDir, raw);
77
+ return { account: { ...coerceAccount(raw), fetchedAt: new Date().toISOString() }, source: "account" };
78
+ }
79
+ catch {
80
+ const cached = await cachedAccount(dataDir);
81
+ return cached
82
+ ? { account: cached, source: "cache" }
83
+ : { account: { plan: null, email: null, quota: null, fetchedAt: null }, source: "none" };
84
+ }
85
+ }
86
+ /**
87
+ * Reads the rules from the account, caching them; falls back to the last cached
88
+ * copy when the control plane cannot be reached, and only to "everything" when
89
+ * this machine has never successfully read them.
90
+ */
91
+ export async function resolveSyncRules(dataDir) {
92
+ try {
93
+ const { accountStatus } = await import("./cloud.js");
94
+ return { rules: await cacheSyncRules(dataDir, await accountStatus(dataDir)), source: "account" };
95
+ }
96
+ catch {
97
+ const cached = await cachedSyncRules(dataDir);
98
+ return cached ? { rules: cached, source: "cache" } : { rules: DEFAULT_SYNC_RULES, source: "default" };
99
+ }
100
+ }
101
+ /**
102
+ * Applies the rules to what was found on disk.
103
+ *
104
+ * `recent` keeps the newest N by modification time. `settled` keeps only what
105
+ * has not been touched for `minAgeDays`, so work still in progress stays put.
106
+ * Both are computed against `now` rather than read from the clock directly, so
107
+ * the behaviour is testable.
108
+ */
109
+ export function applySyncRules(found, rules, now = Date.now()) {
110
+ if (rules.mode === "all")
111
+ return { included: [...found], heldBack: [] };
112
+ const newestFirst = [...found].sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
113
+ const included = rules.mode === "recent"
114
+ ? newestFirst.slice(0, rules.keepLast)
115
+ : newestFirst.filter((item) => now - Date.parse(item.modifiedAt) >= rules.minAgeDays * 86_400_000);
116
+ const keep = new Set(included.map((item) => item.path));
117
+ return { included, heldBack: newestFirst.filter((item) => !keep.has(item.path)) };
118
+ }
119
+ export function describeSyncRules(rules) {
120
+ if (rules.mode === "recent")
121
+ return `the ${rules.keepLast} most recent sessions`;
122
+ if (rules.mode === "settled")
123
+ return `sessions untouched for ${rules.minAgeDays} days`;
124
+ return "every session";
125
+ }
@@ -0,0 +1,15 @@
1
+ export type TrashResult = {
2
+ movedTo: string;
3
+ strategy: "macos-trash" | "xdg-trash" | "windows-recycle-bin" | "vaultline-trash";
4
+ };
5
+ /**
6
+ * Moves a reclaimed source to the operating system's trash, so a mistake is
7
+ * recoverable by the user without Sealkeep's help. Nothing here ever unlinks a
8
+ * file: the worst case is a move into a Sealkeep-owned trash directory.
9
+ */
10
+ export declare function moveToTrash(path: string, options: {
11
+ dataDir: string;
12
+ home?: string;
13
+ strategy?: TrashResult["strategy"];
14
+ }): Promise<TrashResult>;
15
+ export declare function defaultStrategy(): TrashResult["strategy"];
@@ -0,0 +1,63 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, rename, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, platform } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { fail } from "./errors.js";
7
+ const run = promisify(execFile);
8
+ async function uniquePath(directory, name) {
9
+ let candidate = join(directory, name);
10
+ for (let suffix = 1; suffix < 1000; suffix += 1) {
11
+ if (!(await stat(candidate).catch(() => null)))
12
+ return candidate;
13
+ candidate = join(directory, `${name}.${suffix}`);
14
+ }
15
+ return join(directory, `${name}.${Date.now()}`);
16
+ }
17
+ /**
18
+ * Moves a reclaimed source to the operating system's trash, so a mistake is
19
+ * recoverable by the user without Sealkeep's help. Nothing here ever unlinks a
20
+ * file: the worst case is a move into a Sealkeep-owned trash directory.
21
+ */
22
+ export async function moveToTrash(path, options) {
23
+ const home = options.home ?? homedir();
24
+ const strategy = options.strategy ?? defaultStrategy();
25
+ const name = basename(path);
26
+ if (!(await stat(path).catch(() => null)))
27
+ fail("source_unreadable", `Nothing to reclaim at ${path}`, { path });
28
+ if (strategy === "macos-trash") {
29
+ const target = await uniquePath(join(home, ".Trash"), name);
30
+ await mkdir(join(home, ".Trash"), { recursive: true });
31
+ await rename(path, target);
32
+ return { movedTo: target, strategy };
33
+ }
34
+ if (strategy === "xdg-trash") {
35
+ const root = process.env.XDG_DATA_HOME ? join(process.env.XDG_DATA_HOME, "Trash") : join(home, ".local", "share", "Trash");
36
+ await mkdir(join(root, "files"), { recursive: true });
37
+ await mkdir(join(root, "info"), { recursive: true });
38
+ const target = await uniquePath(join(root, "files"), name);
39
+ // The .trashinfo sidecar is what lets a desktop file manager offer "restore".
40
+ await writeFile(`${join(root, "info", basename(target))}.trashinfo`, `[Trash Info]\nPath=${path}\nDeletionDate=${new Date().toISOString().replace(/\.\d{3}Z$/, "")}\n`, { mode: 0o600 });
41
+ await rename(path, target);
42
+ return { movedTo: target, strategy };
43
+ }
44
+ if (strategy === "windows-recycle-bin") {
45
+ const script = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($env:VL_PATH,'OnlyErrorDialogs','SendToRecycleBin')`;
46
+ await run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { env: { ...process.env, VL_PATH: path } });
47
+ return { movedTo: "Recycle Bin", strategy };
48
+ }
49
+ const root = join(options.dataDir, "trash", new Date().toISOString().slice(0, 10));
50
+ await mkdir(root, { recursive: true, mode: 0o700 });
51
+ const target = await uniquePath(root, name);
52
+ await rename(path, target);
53
+ return { movedTo: target, strategy: "vaultline-trash" };
54
+ }
55
+ export function defaultStrategy() {
56
+ if (platform() === "darwin")
57
+ return "macos-trash";
58
+ if (platform() === "linux")
59
+ return "xdg-trash";
60
+ if (platform() === "win32")
61
+ return "windows-recycle-bin";
62
+ return "vaultline-trash";
63
+ }
@@ -0,0 +1,18 @@
1
+ export type View = "overview" | "queue" | "archives" | "retention" | "health";
2
+ export declare const VIEWS: View[];
3
+ export type TuiClient = {
4
+ get(path: string): Promise<any>;
5
+ };
6
+ export declare function apiClient(base: string, token: string): TuiClient;
7
+ export declare function formatBytes(value: number): string;
8
+ /** Renders one frame. Pure so it can be tested without a terminal. */
9
+ export declare function renderFrame(view: View, state: Record<string, any>, width?: number): string;
10
+ export declare function loadState(client: TuiClient): Promise<Record<string, any>>;
11
+ export type TuiOptions = {
12
+ base?: string;
13
+ token?: string;
14
+ out?: NodeJS.WriteStream;
15
+ input?: NodeJS.ReadStream;
16
+ };
17
+ /** Starts the interactive loop. Resolves when the user quits. */
18
+ export declare function runTui(dataDir: string, options?: TuiOptions): Promise<void>;
@@ -0,0 +1,179 @@
1
+ import { PRODUCT_MARK, PRODUCT_NAME } from "./branding.js";
2
+ import { emitKeypressEvents } from "node:readline";
3
+ import { createLocalApiServer, localApiToken } from "./local-api.js";
4
+ import { freePort } from "./net.js";
5
+ /**
6
+ * A keyboard-first terminal view over the local API.
7
+ *
8
+ * It renders with plain ANSI and reads only from `/v1`, so it shows exactly what
9
+ * the dashboard shows and works unchanged over SSH. If no agent is listening it
10
+ * starts a private one on a loopback port for the lifetime of the session.
11
+ */
12
+ const ESC = "[";
13
+ const RESET = `${ESC}0m`;
14
+ const DIM = `${ESC}2m`;
15
+ const BOLD = `${ESC}1m`;
16
+ const GREEN = `${ESC}32m`;
17
+ const YELLOW = `${ESC}33m`;
18
+ const RED = `${ESC}31m`;
19
+ const BLUE = `${ESC}34m`;
20
+ export const VIEWS = ["overview", "queue", "archives", "retention", "health"];
21
+ export function apiClient(base, token) {
22
+ return {
23
+ async get(path) {
24
+ const response = await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${token}` } });
25
+ const body = await response.json().catch(() => ({}));
26
+ if (!response.ok)
27
+ throw new Error(body?.error?.message ?? `${response.status} ${response.statusText}`);
28
+ return body;
29
+ }
30
+ };
31
+ }
32
+ export function formatBytes(value) {
33
+ if (!Number.isFinite(value))
34
+ return "-";
35
+ const units = ["B", "KB", "MB", "GB", "TB"];
36
+ let size = value;
37
+ let unit = 0;
38
+ while (size >= 1024 && unit < units.length - 1) {
39
+ size /= 1024;
40
+ unit += 1;
41
+ }
42
+ return `${unit === 0 ? size : size.toFixed(1)}${units[unit]}`;
43
+ }
44
+ function truncate(value, width) {
45
+ if (value.length <= width)
46
+ return value.padEnd(width);
47
+ return `…${value.slice(-(width - 1))}`;
48
+ }
49
+ function statusColour(status) {
50
+ if (status === "done" || status === "pass")
51
+ return GREEN;
52
+ if (status === "failed" || status === "fail")
53
+ return RED;
54
+ if (status === "warn" || status === "leased")
55
+ return YELLOW;
56
+ return "";
57
+ }
58
+ /** Renders one frame. Pure so it can be tested without a terminal. */
59
+ export function renderFrame(view, state, width = 100) {
60
+ const lines = [];
61
+ const tabs = VIEWS.map((name, index) => (name === view ? `${BLUE}${BOLD}[${index + 1} ${name}]${RESET}` : `${DIM}${index + 1} ${name}${RESET}`)).join(" ");
62
+ lines.push(`${BOLD}${PRODUCT_MARK} ${PRODUCT_NAME}${RESET} ${DIM}${state.status?.vaultId?.slice(0, 8) ?? "no vault"}${RESET}`);
63
+ lines.push(tabs);
64
+ lines.push("─".repeat(Math.min(width, 100)));
65
+ if (view === "overview") {
66
+ const status = state.status ?? {};
67
+ const retention = state.retention ?? {};
68
+ lines.push(`Archives ${BOLD}${status.archiveCount ?? 0}${RESET} (${formatBytes(status.archivedBytes ?? 0)} of source preserved)`);
69
+ lines.push(`Queue ${status.queue?.ready ?? 0} ready · ${status.queue?.leased ?? 0} in flight · ${status.queue?.done ?? 0} archived · ${status.queue?.failed ?? 0} failed`);
70
+ lines.push(`Remote ${status.remoteStorage ? `${status.remoteStorage.provider}://${status.remoteStorage.bucket}/${status.remoteStorage.prefix}` : `${DIM}none configured${RESET}`}`);
71
+ lines.push(`Retention policy ${retention.policy ?? "?"} · ${formatBytes(retention.reclaimableBytes ?? 0)} reclaimable`);
72
+ lines.push("");
73
+ for (const agent of state.agents ?? []) {
74
+ lines.push(`${agent.detected ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`} ${agent.agent.padEnd(8)} ${DIM}${agent.root}${RESET}`);
75
+ }
76
+ }
77
+ if (view === "queue") {
78
+ const jobs = state.queue ?? [];
79
+ lines.push(`${DIM}${"status".padEnd(9)}${"agent".padEnd(9)}${"event".padEnd(13)}${"tries".padEnd(7)}source${RESET}`);
80
+ if (jobs.length === 0)
81
+ lines.push(`${DIM}Nothing queued.${RESET}`);
82
+ for (const job of jobs.slice(0, 20)) {
83
+ lines.push(`${statusColour(job.status)}${job.status.padEnd(9)}${RESET}${job.agent.padEnd(9)}${job.event.padEnd(13)}${String(job.attempts).padEnd(7)}${truncate(job.sourcePath, 46)}`);
84
+ }
85
+ }
86
+ if (view === "archives") {
87
+ const archives = state.archives ?? [];
88
+ lines.push(`${DIM}${"created".padEnd(22)}${"agent".padEnd(9)}${"bytes".padEnd(9)}${"remote".padEnd(11)}source${RESET}`);
89
+ if (archives.length === 0)
90
+ lines.push(`${DIM}No archives yet.${RESET}`);
91
+ for (const archive of archives.slice(0, 20)) {
92
+ const remote = archive.remote ? `${GREEN}verified${RESET} ` : `${DIM}local${RESET} `;
93
+ lines.push(`${new Date(archive.createdAt).toISOString().slice(0, 19).replace("T", " ").padEnd(22)}${archive.agent.padEnd(9)}${formatBytes(archive.bytes).padEnd(9)}${remote}${truncate(archive.path, 38)}`);
94
+ }
95
+ }
96
+ if (view === "retention") {
97
+ const retention = state.retention ?? {};
98
+ lines.push(`Policy ${BOLD}${retention.policy ?? "?"}${RESET} · older than ${retention.olderThanDays ?? "?"} days · ${retention.graceDays ?? "?"}-day grace`);
99
+ lines.push(`${DIM}Reclaimed sources move to the OS trash. Preview never touches the disk.${RESET}`);
100
+ lines.push("");
101
+ for (const candidate of (retention.candidates ?? []).slice(0, 15)) {
102
+ lines.push(`${candidate.eligible ? `${GREEN}eligible${RESET}` : `${DIM}held ${RESET}`} ${truncate(candidate.path, 40)} ${DIM}${candidate.blockers?.[0] ?? ""}${RESET}`);
103
+ }
104
+ if ((retention.candidates ?? []).length === 0)
105
+ lines.push(`${DIM}No local sources are tracked for reclamation.${RESET}`);
106
+ }
107
+ if (view === "health") {
108
+ for (const check of state.doctor?.checks ?? []) {
109
+ lines.push(`${statusColour(check.status)}${check.status.padEnd(6)}${RESET}${check.name.padEnd(20)}${DIM}${check.detail}${RESET}`);
110
+ }
111
+ }
112
+ lines.push("");
113
+ lines.push(`${DIM}1-5 switch view · r refresh · q quit${RESET}`);
114
+ if (state.error)
115
+ lines.push(`${RED}${state.error}${RESET}`);
116
+ return lines.join("\n");
117
+ }
118
+ export async function loadState(client) {
119
+ const [status, queue, archives, retention, doctor, agents] = await Promise.all([
120
+ client.get("/v1/status"), client.get("/v1/queue"), client.get("/v1/archives"),
121
+ client.get("/v1/retention"), client.get("/v1/doctor"), client.get("/v1/agents")
122
+ ]);
123
+ return { status, queue, archives, retention, doctor, agents };
124
+ }
125
+ /** Starts the interactive loop. Resolves when the user quits. */
126
+ export async function runTui(dataDir, options = {}) {
127
+ const out = options.out ?? process.stdout;
128
+ const input = options.input ?? process.stdin;
129
+ let owned;
130
+ let base = options.base;
131
+ const token = options.token ?? await localApiToken(dataDir);
132
+ if (!base) {
133
+ owned = createLocalApiServer(dataDir, token);
134
+ const port = await freePort(4190);
135
+ await new Promise((ready) => owned.listen(port, "127.0.0.1", ready));
136
+ base = `http://127.0.0.1:${port}`;
137
+ }
138
+ const client = apiClient(base, token);
139
+ let view = "overview";
140
+ let state = {};
141
+ async function draw() {
142
+ try {
143
+ state = { ...(await loadState(client)) };
144
+ }
145
+ catch (error) {
146
+ state = { ...state, error: error instanceof Error ? error.message : "could not read local state" };
147
+ }
148
+ out.write(`${ESC}2J${ESC}H${renderFrame(view, state, out.columns ?? 100)}\n`);
149
+ }
150
+ emitKeypressEvents(input);
151
+ if (input.isTTY)
152
+ input.setRawMode(true);
153
+ input.resume();
154
+ await draw();
155
+ await new Promise((done) => {
156
+ const onKey = (_chunk, key) => {
157
+ if (key?.name === "q" || (key?.ctrl && key?.name === "c")) {
158
+ input.off("keypress", onKey);
159
+ if (input.isTTY)
160
+ input.setRawMode(false);
161
+ input.pause();
162
+ out.write(`${ESC}0m\n`);
163
+ done();
164
+ return;
165
+ }
166
+ if (key?.name === "r") {
167
+ void draw();
168
+ return;
169
+ }
170
+ const index = Number(key?.sequence ?? key?.name);
171
+ if (Number.isInteger(index) && index >= 1 && index <= VIEWS.length) {
172
+ view = VIEWS[index - 1];
173
+ void draw();
174
+ }
175
+ };
176
+ input.on("keypress", onKey);
177
+ });
178
+ await new Promise((closed) => (owned ? owned.close(() => closed()) : closed()));
179
+ }
@@ -0,0 +1,191 @@
1
+ import type { Envelope, Suite } from "../packages/vaultline-crypto/src/index.js";
2
+ /** The original prototype envelope. Still readable; never written by this build. */
3
+ export type ArchiveRecordV1 = {
4
+ version: 1;
5
+ id: string;
6
+ createdAt: string;
7
+ source: {
8
+ path: string;
9
+ agent: string;
10
+ bytes: number;
11
+ sha256: string;
12
+ };
13
+ cipher: {
14
+ algorithm: "aes-256-gcm";
15
+ nonce: string;
16
+ authTag: string;
17
+ ciphertextSha256: string;
18
+ compression: "none";
19
+ };
20
+ wrappedKey: {
21
+ algorithm: "scrypt-aes-256-gcm";
22
+ salt: string;
23
+ nonce: string;
24
+ authTag: string;
25
+ ciphertext: string;
26
+ };
27
+ objectPath: string;
28
+ };
29
+ /** A remote copy the client uploaded and then verified. Written only after verification. */
30
+ export type RemoteObject = {
31
+ provider: string;
32
+ bucket: string;
33
+ /** Single-object layout: the object's key. Chunk layout: the FOLDER prefix (no trailing slash). */
34
+ objectKey: string;
35
+ bytes: number;
36
+ /** Base64 SHA-256 of the stored ciphertext, confirmed against the provider. */
37
+ checksum: string;
38
+ verifiedAt: string;
39
+ /**
40
+ * Present when the remote is a FOLDER of chunk objects (`chunk-000000`…)
41
+ * plus an `envelope.vlmeta` sidecar, instead of one blob. Every chunk is
42
+ * independently sealed and independently fetchable; the sidecar makes the
43
+ * folder self-recovering with nothing but the recovery phrase. Absent means
44
+ * the original single-object layout, which stays readable forever.
45
+ */
46
+ layout?: {
47
+ kind: "chunks";
48
+ chunkCount: number;
49
+ };
50
+ /** Which configured storage target this copy went to, for usage accounting. */
51
+ targetId?: string;
52
+ };
53
+ /** The current record: a `vaultline-crypto` v2 envelope plus local bookkeeping. */
54
+ export type ArchiveRecordV2 = {
55
+ version: 2;
56
+ id: string;
57
+ createdAt: string;
58
+ source: {
59
+ path: string;
60
+ agent: string;
61
+ bytes: number;
62
+ sha256: string;
63
+ };
64
+ /** Summary of the stored object, shared with v1 so callers can treat both alike. */
65
+ cipher: {
66
+ algorithm: Suite;
67
+ ciphertextSha256: string;
68
+ storedBytes: number;
69
+ chunks: number;
70
+ };
71
+ envelope: Envelope;
72
+ objectPath: string;
73
+ remote?: RemoteObject;
74
+ /** Set once the local source has been moved to the trash. The archive itself stays. */
75
+ reclaimed?: {
76
+ at: string;
77
+ trashPath: string;
78
+ strategy: string;
79
+ };
80
+ /**
81
+ * Set once the local *archive* has been removed because a verified remote
82
+ * copy exists. The archive is not gone — it is in the bucket, and restoring
83
+ * fetches it back — but `objectPath` no longer names a file.
84
+ *
85
+ * This is what makes reclaiming actually free the disk. Removing the source
86
+ * alone leaves a sealed copy that is about two thirds of it on real
87
+ * transcripts, so a machine with 78 GB of sessions kept 54 GB of archives and
88
+ * got back a quarter of what it was promised.
89
+ */
90
+ offloaded?: {
91
+ at: string;
92
+ provider: string;
93
+ bucket: string;
94
+ objectKey: string;
95
+ };
96
+ /**
97
+ * The archive whose bytes this one re-archived, and how many of them.
98
+ *
99
+ * Agent transcripts only ever grow, so a snapshot taken after another one has
100
+ * that archive's whole content inside it — a 4 GB session snapshotted ten
101
+ * times spends 40 GB of storage on 4 GB of transcript. This names the
102
+ * redundancy where it can be seen, proved at archive time by re-reading and
103
+ * hashing the earlier archive's byte range (`provenAppendOffset`).
104
+ *
105
+ * It changes nothing about how the archive is read: every archive is still
106
+ * complete and standalone, which is why the record version does not move. It
107
+ * is the pointer a delta archive would seal from.
108
+ */
109
+ supersedes?: {
110
+ archiveId: string;
111
+ bytes: number;
112
+ };
113
+ /**
114
+ * Present only on a delta archive: this record's ciphertext holds bytes
115
+ * `[baseBytes, source.bytes)` of the transcript, and the named base archive
116
+ * (itself possibly a delta) holds the rest. `source` still describes the
117
+ * WHOLE file, so dedupe, retention's size gate and prefix-proofs stay
118
+ * honest. A delta NEEDS its base — which is why a delta never writes
119
+ * `supersedes`: that field offers its target for deletion.
120
+ */
121
+ delta?: {
122
+ baseArchiveId: string;
123
+ baseBytes: number;
124
+ };
125
+ };
126
+ export type ArchiveRecord = ArchiveRecordV1 | ArchiveRecordV2;
127
+ export type StorageTargetConfig = {
128
+ id: string;
129
+ provider: "vaultline" | "s3" | "r2" | "b2" | "gcs" | "gdrive";
130
+ bucket?: string;
131
+ prefix?: string;
132
+ region?: string;
133
+ maxGb?: number;
134
+ projects?: string[];
135
+ priority?: number;
136
+ };
137
+ export declare function isV2(record: ArchiveRecord): record is ArchiveRecordV2;
138
+ export type VaultConfig = {
139
+ version: 1;
140
+ vaultId: string;
141
+ storage: {
142
+ provider: "local";
143
+ root: string;
144
+ };
145
+ /**
146
+ * Chosen once, at creation. Absent means sealed, so every vault written
147
+ * before this field existed keeps loading — and keeps being read as
148
+ * encrypted, which is what it is. "plain" stores archives as bare gzip for
149
+ * people who want portable, readable memory; the recovery phrase still
150
+ * exists and still identifies the vault, but protects nothing in this mode.
151
+ */
152
+ storageMode?: "sealed" | "plain";
153
+ /** Desired remote target. Credentials and signed URLs never live here. */
154
+ remoteStorage?: {
155
+ provider: "vaultline" | "s3" | "r2" | "b2" | "gcs" | "gdrive";
156
+ bucket: string;
157
+ prefix: string;
158
+ region?: string;
159
+ };
160
+ /** Multiple simultaneous storage targets with routing rules; wins over remoteStorage when present. */
161
+ storageTargets?: StorageTargetConfig[];
162
+ recovery: {
163
+ phraseCheck: string;
164
+ };
165
+ /** X25519 recipients that may also open new archives: devices and extra recovery keys. */
166
+ recipients?: {
167
+ id: string;
168
+ label: string;
169
+ publicKey: string;
170
+ addedAt: string;
171
+ group?: string;
172
+ }[];
173
+ /** `deleteAfterDays` is optional so vaults written before archive lifecycle existed still load; absent means never. */
174
+ retention?: {
175
+ policy: "local-only" | "sync-only" | "archive-and-reclaim" | "manual-approval";
176
+ olderThanDays: number;
177
+ graceDays: number;
178
+ deleteAfterDays?: number | null;
179
+ };
180
+ /** Ed25519 public keys, by key id, that may sign a release this client will accept. */
181
+ updateKeys?: Record<string, string>;
182
+ /** How many seals may run at once (1–4). Absent means one at a time. */
183
+ sealConcurrency?: number;
184
+ /**
185
+ * How chunk-folder remotes are named. "readable" (default): project/date/
186
+ * session as words — browsable in your own bucket's console. "hashed":
187
+ * the same structure with every segment HMAC'd under a phrase-derived key —
188
+ * grouping without meaning, for buckets where names would leak.
189
+ */
190
+ remoteNaming?: "readable" | "hashed";
191
+ };