knodin 0.8.7 → 0.9.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 (44) hide show
  1. package/dist/bin/cli.js +307 -85
  2. package/dist/fixtures/update-verification/config/application.xml +4 -0
  3. package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
  4. package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
  5. package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
  6. package/dist/fixtures/update-verification/fixture.json +147 -0
  7. package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
  8. package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
  9. package/dist/fixtures/update-verification/package.json +5 -0
  10. package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
  11. package/dist/fixtures/update-verification/sql/functions.sql +16 -0
  12. package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
  13. package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
  14. package/dist/fixtures/update-verification/src/javascript.js +7 -0
  15. package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
  16. package/dist/fixtures/update-verification/src/typescript.ts +7 -0
  17. package/dist/fixtures/update-verification/terraform/main.tf +7 -0
  18. package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
  19. package/dist/src/agent-integration.js +26 -3
  20. package/dist/src/backup-retention.js +811 -0
  21. package/dist/src/cli-model.js +35 -8
  22. package/dist/src/docs-sections.js +1 -0
  23. package/dist/src/doctor.js +142 -12
  24. package/dist/src/engine/embeddings.js +8 -1
  25. package/dist/src/engine/index.js +355 -292
  26. package/dist/src/init.js +222 -179
  27. package/dist/src/manager-update.js +455 -0
  28. package/dist/src/release-preflight.js +106 -95
  29. package/dist/src/repair-lease.js +77 -8
  30. package/dist/src/response-budget.js +5 -1
  31. package/dist/src/server.js +18 -5
  32. package/dist/src/shared-index/compatibility.js +4 -2
  33. package/dist/src/tools/knodin-tools.js +69 -10
  34. package/dist/src/update-ceremony.js +20 -24
  35. package/dist/src/update-coordination.js +158 -0
  36. package/dist/src/update-executor.js +336 -0
  37. package/dist/src/update-verifier.js +358 -0
  38. package/docs/BACKUP-RETENTION.md +54 -0
  39. package/docs/CLI.md +6 -0
  40. package/docs/DOCTOR-AND-UPDATES.md +44 -12
  41. package/docs/SIGNED-UPDATES.md +29 -22
  42. package/docs/releases/0.9.0.md +43 -0
  43. package/package.json +7 -1
  44. package/roadmap/competitive-roadmap.md +8 -0
@@ -0,0 +1,811 @@
1
+ import childProcess from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import YAML from "yaml";
7
+ import { acquireRepairLease } from "./repair-lease.js";
8
+ const BACKUP = /^db\.sqlite\.backup\.(\d{13})\.([0-9a-f]{8})$/;
9
+ const PRUNED_DIRECTORIES = new Set([
10
+ ".git",
11
+ "node_modules",
12
+ "target",
13
+ "dist",
14
+ "build",
15
+ ".venv",
16
+ ".next",
17
+ "vendor",
18
+ ]);
19
+ const DAY_MS = 86_400_000;
20
+ function homes(options = {}) {
21
+ const home = options.homeDir ?? os.homedir();
22
+ const configHome = path.resolve(options.configHome ?? process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"));
23
+ const stateHome = path.resolve(options.stateHome ?? process.env.XDG_STATE_HOME ?? path.join(home, ".local", "state"));
24
+ return {
25
+ home,
26
+ configHome,
27
+ stateHome,
28
+ policy: path.join(configHome, "knodin", "backup-retention.json"),
29
+ stateRoot: path.join(stateHome, "knodin", "backup-retention"),
30
+ };
31
+ }
32
+ function canonicalDirectory(candidate) {
33
+ try {
34
+ const stat = fs.lstatSync(candidate);
35
+ if (!stat.isDirectory() || stat.isSymbolicLink())
36
+ return null;
37
+ return fs.realpathSync(candidate);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function registryPaths(configHome) {
44
+ try {
45
+ const parsed = YAML.parse(fs.readFileSync(path.join(configHome, "knodin", "repositories.yaml"), "utf8"));
46
+ return Object.values(parsed?.repositories ?? {}).flatMap((value) => typeof value === "string" && path.isAbsolute(value) ? [value] : []);
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ }
52
+ function isRepository(candidate) {
53
+ try {
54
+ const state = fs.lstatSync(path.join(candidate, ".knodin"));
55
+ return state.isDirectory() && !state.isSymbolicLink();
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ export function discoverBackupRepositories(roots, options = {}) {
62
+ const depth = options.depth ?? 5;
63
+ if (!Number.isInteger(depth) || depth < 0 || depth > 32)
64
+ throw new Error("knodin backups: --depth must be an integer from 0 to 32");
65
+ const { configHome } = homes(options);
66
+ const repositories = new Set();
67
+ const issues = [];
68
+ const walk = (directory, remaining) => {
69
+ const canonical = canonicalDirectory(directory);
70
+ if (!canonical) {
71
+ issues.push({ repository: path.resolve(directory), reason: "missing-or-unsafe-root" });
72
+ return;
73
+ }
74
+ if (isRepository(canonical))
75
+ repositories.add(canonical);
76
+ if (remaining === 0)
77
+ return;
78
+ let entries;
79
+ try {
80
+ entries = fs.readdirSync(canonical, { withFileTypes: true });
81
+ }
82
+ catch (error) {
83
+ issues.push({ repository: canonical, reason: `unreadable-root: ${message(error)}` });
84
+ return;
85
+ }
86
+ const orderedEntries = [...entries];
87
+ orderedEntries.sort((a, b) => a.name.localeCompare(b.name));
88
+ for (const entry of orderedEntries) {
89
+ if (!entry.isDirectory() || entry.isSymbolicLink() || PRUNED_DIRECTORIES.has(entry.name))
90
+ continue;
91
+ walk(path.join(canonical, entry.name), remaining - 1);
92
+ }
93
+ };
94
+ for (const root of roots)
95
+ walk(path.resolve(root), depth);
96
+ if (options.includeRegistry) {
97
+ for (const registered of registryPaths(configHome)) {
98
+ const canonical = canonicalDirectory(registered);
99
+ if (canonical && isRepository(canonical))
100
+ repositories.add(canonical);
101
+ }
102
+ }
103
+ return { repositories: [...repositories].sort((a, b) => a.localeCompare(b)), issues };
104
+ }
105
+ function message(error) {
106
+ return error instanceof Error ? error.message : String(error);
107
+ }
108
+ function inventory(repository) {
109
+ const stateRoot = path.join(repository, ".knodin");
110
+ const backups = [];
111
+ const skipped = [];
112
+ let entries;
113
+ try {
114
+ entries = fs.readdirSync(stateRoot, { withFileTypes: true });
115
+ }
116
+ catch (error) {
117
+ return { backups, skipped: [{ repository, reason: `inventory-failed: ${message(error)}` }] };
118
+ }
119
+ for (const entry of entries) {
120
+ const match = BACKUP.exec(entry.name);
121
+ if (!match)
122
+ continue;
123
+ const target = path.join(stateRoot, entry.name);
124
+ try {
125
+ const stat = fs.lstatSync(target);
126
+ if (!stat.isFile() || stat.isSymbolicLink()) {
127
+ skipped.push({ repository, path: target, reason: "not-a-regular-file" });
128
+ continue;
129
+ }
130
+ const timestamp = Number(match[1]);
131
+ backups.push({
132
+ repository,
133
+ path: target,
134
+ createdAt: new Date(timestamp).toISOString(),
135
+ timestamp,
136
+ bytes: stat.size,
137
+ device: stat.dev,
138
+ inode: stat.ino,
139
+ modifiedAtMs: stat.mtimeMs,
140
+ });
141
+ }
142
+ catch (error) {
143
+ skipped.push({ repository, path: target, reason: `stat-failed: ${message(error)}` });
144
+ }
145
+ }
146
+ backups.sort((a, b) => b.timestamp - a.timestamp || a.path.localeCompare(b.path));
147
+ return { backups, skipped };
148
+ }
149
+ export function listBackups(roots, options = {}) {
150
+ const discovery = discoverBackupRepositories(roots, options);
151
+ const backups = [];
152
+ const skipped = [...discovery.issues];
153
+ for (const repository of discovery.repositories) {
154
+ const found = inventory(repository);
155
+ backups.push(...found.backups);
156
+ skipped.push(...found.skipped);
157
+ }
158
+ backups.sort((a, b) => a.repository.localeCompare(b.repository) || b.timestamp - a.timestamp);
159
+ return {
160
+ schemaVersion: 1,
161
+ repositories: discovery.repositories,
162
+ backups,
163
+ count: backups.length,
164
+ totalBytes: backups.reduce((sum, entry) => sum + entry.bytes, 0),
165
+ skipped,
166
+ };
167
+ }
168
+ function safeLiveDatabase(repository) {
169
+ const statePath = path.join(repository, ".knodin");
170
+ const stateStat = fs.lstatSync(statePath);
171
+ if (!stateStat.isDirectory() || stateStat.isSymbolicLink())
172
+ return null;
173
+ const stateRoot = fs.realpathSync(statePath);
174
+ const live = path.join(stateRoot, "db.sqlite");
175
+ const stat = fs.lstatSync(live);
176
+ if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(live)) !== stateRoot)
177
+ return null;
178
+ const descriptor = fs.openSync(live, "r");
179
+ try {
180
+ const header = Buffer.alloc(16);
181
+ if (fs.readSync(descriptor, header, 0, header.length, 0) !== header.length)
182
+ return null;
183
+ if (!header.equals(Buffer.from("SQLite format 3\0", "binary")))
184
+ return null;
185
+ }
186
+ finally {
187
+ fs.closeSync(descriptor);
188
+ }
189
+ return live;
190
+ }
191
+ function markerExists(target) {
192
+ try {
193
+ fs.lstatSync(target);
194
+ return true;
195
+ }
196
+ catch (error) {
197
+ if (error.code === "ENOENT")
198
+ return false;
199
+ throw error;
200
+ }
201
+ }
202
+ export function pruneBackups(roots, options = {}) {
203
+ const now = options.now ?? new Date();
204
+ const retentionDays = options.retentionDays ?? 3;
205
+ const keepNewest = options.keepNewest ?? 1;
206
+ if (!Number.isInteger(retentionDays) || retentionDays < 0 || retentionDays > 3650)
207
+ throw new Error("knodin backups: --retention-days must be an integer from 0 to 3650");
208
+ if (!Number.isInteger(keepNewest) || keepNewest < 0 || keepNewest > 1000)
209
+ throw new Error("knodin backups: --keep-newest must be an integer from 0 to 1000");
210
+ const listed = listBackups(roots, options);
211
+ const cutoff = now.getTime() - retentionDays * DAY_MS;
212
+ const candidates = [];
213
+ const deleted = [];
214
+ const skipped = [...listed.skipped];
215
+ for (const repository of listed.repositories) {
216
+ const repositoryBackups = listed.backups.filter((entry) => entry.repository === repository);
217
+ const planned = repositoryBackups.slice(keepNewest).filter((entry) => entry.timestamp < cutoff);
218
+ candidates.push(...planned);
219
+ if (!options.apply || planned.length === 0)
220
+ continue;
221
+ let lease;
222
+ try {
223
+ lease = acquireRepairLease(repository, "backups prune");
224
+ const marker = path.join(repository, ".knodin", "promotion.json");
225
+ if (markerExists(marker))
226
+ throw new Error("promotion-in-progress-or-malformed-marker");
227
+ if (!safeLiveDatabase(repository))
228
+ throw new Error("missing-or-unsafe-live-database");
229
+ const stateRoot = fs.realpathSync(path.join(repository, ".knodin"));
230
+ for (const candidate of planned) {
231
+ try {
232
+ const name = path.basename(candidate.path);
233
+ if (!BACKUP.test(name) || path.dirname(candidate.path) !== stateRoot)
234
+ throw new Error("candidate-containment-changed");
235
+ const stat = fs.lstatSync(candidate.path);
236
+ if (!stat.isFile() ||
237
+ stat.isSymbolicLink() ||
238
+ stat.size !== candidate.bytes ||
239
+ stat.dev !== candidate.device ||
240
+ stat.ino !== candidate.inode ||
241
+ stat.mtimeMs !== candidate.modifiedAtMs)
242
+ throw new Error("candidate-changed-after-plan");
243
+ fs.unlinkSync(candidate.path);
244
+ deleted.push(candidate);
245
+ }
246
+ catch (error) {
247
+ skipped.push({ repository, path: candidate.path, reason: message(error) });
248
+ }
249
+ }
250
+ }
251
+ catch (error) {
252
+ skipped.push({ repository, reason: message(error) });
253
+ }
254
+ finally {
255
+ lease?.release();
256
+ }
257
+ }
258
+ return {
259
+ schemaVersion: 1,
260
+ mode: options.apply ? "apply" : "preview",
261
+ retentionDays,
262
+ keepNewest,
263
+ candidates,
264
+ candidateCount: candidates.length,
265
+ reclaimableBytes: candidates.reduce((sum, entry) => sum + entry.bytes, 0),
266
+ deleted,
267
+ deletedBytes: deleted.reduce((sum, entry) => sum + entry.bytes, 0),
268
+ skipped,
269
+ };
270
+ }
271
+ function digest(content) {
272
+ return crypto.createHash("sha256").update(content).digest("hex");
273
+ }
274
+ function atomicWrite(target, content, mode = 0o600) {
275
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
276
+ const temporary = `${target}.tmp-${process.pid}-${crypto.randomUUID()}`;
277
+ fs.writeFileSync(temporary, content, { mode, flag: "wx" });
278
+ fs.renameSync(temporary, target);
279
+ }
280
+ function readJson(target) {
281
+ try {
282
+ return JSON.parse(fs.readFileSync(target, "utf8"));
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ }
288
+ function exactKeys(value, keys) {
289
+ const alphabetically = (left, right) => left.localeCompare(right);
290
+ return (Object.keys(value).sort(alphabetically).join("\0") === [...keys].sort(alphabetically).join("\0"));
291
+ }
292
+ function parseRetentionPolicy(value) {
293
+ if (!value || typeof value !== "object" || Array.isArray(value))
294
+ return null;
295
+ const record = value;
296
+ if (!exactKeys(record, [
297
+ "schemaVersion",
298
+ "installedAt",
299
+ "roots",
300
+ "retentionDays",
301
+ "keepNewest",
302
+ "depth",
303
+ "schedule",
304
+ "launcher",
305
+ "dryRun",
306
+ ]) ||
307
+ record.schemaVersion !== 1 ||
308
+ typeof record.installedAt !== "string" ||
309
+ !Number.isFinite(Date.parse(record.installedAt)) ||
310
+ !Array.isArray(record.roots) ||
311
+ record.roots.length === 0 ||
312
+ !record.roots.every((root) => typeof root === "string" && path.isAbsolute(root)) ||
313
+ !Number.isInteger(record.retentionDays) ||
314
+ record.retentionDays < 0 ||
315
+ record.retentionDays > 3650 ||
316
+ !Number.isInteger(record.keepNewest) ||
317
+ record.keepNewest < 0 ||
318
+ record.keepNewest > 1000 ||
319
+ !Number.isInteger(record.depth) ||
320
+ record.depth < 0 ||
321
+ record.depth > 32 ||
322
+ typeof record.schedule !== "string" ||
323
+ !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(record.schedule) ||
324
+ !Array.isArray(record.launcher) ||
325
+ record.launcher.length === 0 ||
326
+ !record.launcher.every((part) => typeof part === "string") ||
327
+ !path.isAbsolute(record.launcher[0]) ||
328
+ typeof record.dryRun !== "boolean")
329
+ return null;
330
+ return record;
331
+ }
332
+ function parseRetentionReceipt(value) {
333
+ if (!value || typeof value !== "object" || Array.isArray(value))
334
+ return null;
335
+ const record = value;
336
+ if (!exactKeys(record, ["schemaVersion", "platform", "installedAt", "policyDigest", "artifacts"]) ||
337
+ record.schemaVersion !== 1 ||
338
+ !["darwin", "linux", "win32"].includes(record.platform) ||
339
+ typeof record.installedAt !== "string" ||
340
+ !Number.isFinite(Date.parse(record.installedAt)) ||
341
+ typeof record.policyDigest !== "string" ||
342
+ !/^[0-9a-f]{64}$/.test(record.policyDigest) ||
343
+ !Array.isArray(record.artifacts) ||
344
+ record.artifacts.length === 0 ||
345
+ !record.artifacts.every((artifact) => artifact &&
346
+ typeof artifact === "object" &&
347
+ !Array.isArray(artifact) &&
348
+ exactKeys(artifact, ["path", "digest"]) &&
349
+ typeof artifact.path === "string" &&
350
+ path.isAbsolute(artifact.path) &&
351
+ typeof artifact.digest === "string" &&
352
+ /^[0-9a-f]{64}$/.test(artifact.digest)))
353
+ return null;
354
+ return record;
355
+ }
356
+ function readInstalledRetention(options) {
357
+ const locations = homes(options);
358
+ const policy = parseRetentionPolicy(readJson(locations.policy));
359
+ const receipt = parseRetentionReceipt(readJson(path.join(locations.stateRoot, "receipt.json")));
360
+ const issues = [];
361
+ if (!policy)
362
+ issues.push("policy-missing-or-invalid");
363
+ if (!receipt)
364
+ issues.push("receipt-missing-or-invalid");
365
+ if (policy && receipt && digest(`${JSON.stringify(policy, null, 2)}\n`) !== receipt.policyDigest)
366
+ issues.push("policy-receipt-mismatch");
367
+ for (const artifact of receipt?.artifacts ?? []) {
368
+ try {
369
+ if (digest(fs.readFileSync(artifact.path, "utf8")) !== artifact.digest)
370
+ issues.push(`scheduler-content-mismatch: ${artifact.path}`);
371
+ }
372
+ catch {
373
+ issues.push(`scheduler-missing: ${artifact.path}`);
374
+ }
375
+ }
376
+ return { policy, receipt, issues };
377
+ }
378
+ function commandRunner(command, args) {
379
+ const result = childProcess.spawnSync(command, args, { encoding: "utf8" });
380
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
381
+ }
382
+ function schedulerArtifacts(policy, options) {
383
+ const { home, stateRoot } = homes(options);
384
+ const platform = options.platform ?? process.platform;
385
+ const command = [...policy.launcher, "backups", "retention", "run", "--json"];
386
+ if (platform === "darwin") {
387
+ const [hour, minute] = policy.schedule.split(":").map(Number);
388
+ const argumentsXml = command.map((value) => `<string>${escapeXml(value)}</string>`).join("");
389
+ return [
390
+ {
391
+ path: path.join(home, "Library", "LaunchAgents", "dev.knodin.backup-retention.plist"),
392
+ content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>dev.knodin.backup-retention</string><key>ProgramArguments</key><array>${argumentsXml}</array><key>RunAtLoad</key><true/><key>StartCalendarInterval</key><dict><key>Hour</key><integer>${hour}</integer><key>Minute</key><integer>${minute}</integer></dict><key>StandardOutPath</key><string>${escapeXml(path.join(stateRoot, "scheduler.log"))}</string><key>StandardErrorPath</key><string>${escapeXml(path.join(stateRoot, "scheduler.log"))}</string></dict></plist>\n`,
393
+ },
394
+ ];
395
+ }
396
+ if (platform === "win32") {
397
+ const executable = command[0] ?? "knodin";
398
+ const args = command.slice(1).map(windowsQuote).join(" ");
399
+ return [
400
+ {
401
+ path: path.join(stateRoot, "dev.knodin.backup-retention.xml"),
402
+ content: `<?xml version="1.0" encoding="UTF-8"?>\n<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"><Triggers><CalendarTrigger><StartBoundary>2000-01-01T${policy.schedule}:00</StartBoundary><ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay></CalendarTrigger><LogonTrigger/></Triggers><Settings><StartWhenAvailable>true</StartWhenAvailable></Settings><Actions Context="Author"><Exec><Command>${escapeXml(executable)}</Command><Arguments>${escapeXml(args)}</Arguments></Exec></Actions></Task>\n`,
403
+ },
404
+ ];
405
+ }
406
+ const service = `[Unit]\nDescription=Knodin backup retention\n[Service]\nType=oneshot\nExecStart=${command.map(systemdQuote).join(" ")}\n`;
407
+ const timer = `[Unit]\nDescription=Daily Knodin backup retention\n[Timer]\nOnCalendar=*-*-* ${policy.schedule}:00\nPersistent=true\n[Install]\nWantedBy=timers.target\n`;
408
+ const systemd = path.join(home, ".config", "systemd", "user");
409
+ return [
410
+ { path: path.join(systemd, "knodin-backup-retention.service"), content: service },
411
+ { path: path.join(systemd, "knodin-backup-retention.timer"), content: timer },
412
+ ];
413
+ }
414
+ function escapeXml(value) {
415
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
416
+ }
417
+ function systemdQuote(value) {
418
+ return value.replaceAll("%", "%%").replace(/([\\\s"'])/g, "\\$1");
419
+ }
420
+ function windowsQuote(value) {
421
+ return `"${value.replaceAll('"', '\\"')}"`;
422
+ }
423
+ function validateSchedule(value) {
424
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value))
425
+ throw new Error("knodin backups retention: --schedule must be HH:MM local time");
426
+ return value;
427
+ }
428
+ function schedulerRun(platform, action, run, artifacts) {
429
+ const checked = (command, args) => {
430
+ const result = run(command, args);
431
+ if (result.status !== 0)
432
+ throw new Error(`knodin backups retention: scheduler command failed (${command} ${args.join(" ")}): ${result.stderr || result.stdout || `exit ${result.status}`}`);
433
+ return result;
434
+ };
435
+ if (platform === "darwin") {
436
+ const target = artifacts[0]?.path;
437
+ if (target)
438
+ checked("launchctl", [action === "install" ? "load" : "unload", target]);
439
+ }
440
+ else if (platform === "linux") {
441
+ checked("systemctl", ["--user", "daemon-reload"]);
442
+ checked("systemctl", [
443
+ "--user",
444
+ action === "install" ? "enable" : "disable",
445
+ "--now",
446
+ "knodin-backup-retention.timer",
447
+ ]);
448
+ }
449
+ else if (platform === "win32") {
450
+ if (action === "install")
451
+ checked("schtasks", [
452
+ "/Create",
453
+ "/TN",
454
+ "Knodin Backup Retention",
455
+ "/XML",
456
+ artifacts[0]?.path ?? "",
457
+ "/F",
458
+ ]);
459
+ else
460
+ checked("schtasks", ["/Delete", "/TN", "Knodin Backup Retention", "/F"]);
461
+ }
462
+ }
463
+ export function installBackupRetention(roots, options = {}) {
464
+ const discovered = discoverBackupRepositories(roots, { ...options, includeRegistry: true });
465
+ if (discovered.repositories.length === 0)
466
+ throw new Error("knodin backups retention install: no repositories found in roots or registry");
467
+ if (options.retentionDays !== undefined &&
468
+ (!Number.isInteger(options.retentionDays) ||
469
+ options.retentionDays < 0 ||
470
+ options.retentionDays > 3650))
471
+ throw new Error("knodin backups retention: --retention-days must be an integer from 0 to 3650");
472
+ if (options.keepNewest !== undefined &&
473
+ (!Number.isInteger(options.keepNewest) || options.keepNewest < 0 || options.keepNewest > 1000))
474
+ throw new Error("knodin backups retention: --keep-newest must be an integer from 0 to 1000");
475
+ const { policy: policyPath, stateRoot } = homes(options);
476
+ const now = new Date();
477
+ const launcher = (options.launcher ?? [process.execPath]).map((entry, index) => index === 0 ? path.resolve(entry) : entry);
478
+ const policy = {
479
+ schemaVersion: 1,
480
+ installedAt: now.toISOString(),
481
+ roots: [
482
+ ...new Set([
483
+ ...roots.map((root) => path.resolve(root)),
484
+ ...registryPaths(homes(options).configHome).flatMap((entry) => {
485
+ const canonical = canonicalDirectory(entry);
486
+ return canonical && isRepository(canonical) ? [canonical] : [];
487
+ }),
488
+ ]),
489
+ ].sort((a, b) => a.localeCompare(b)),
490
+ retentionDays: options.retentionDays ?? 3,
491
+ keepNewest: options.keepNewest ?? 1,
492
+ depth: options.depth ?? 5,
493
+ schedule: validateSchedule(options.schedule ?? "04:17"),
494
+ launcher,
495
+ dryRun: options.dryRun ?? false,
496
+ };
497
+ const platform = options.platform ?? process.platform;
498
+ if (!["darwin", "linux", "win32"].includes(platform))
499
+ throw new Error(`knodin backups retention: scheduler unsupported on ${platform}`);
500
+ const previousInstalled = readInstalledRetention(options);
501
+ const previous = previousInstalled.issues.length === 0 ? previousInstalled.receipt : null;
502
+ const artifacts = schedulerArtifacts(policy, options);
503
+ const run = options.run ?? commandRunner;
504
+ if (platform === "win32") {
505
+ const registered = run("schtasks", ["/Query", "/TN", "Knodin Backup Retention", "/XML"]);
506
+ if (registered.status === 0 && !previous)
507
+ throw new Error("knodin backups retention: refusing unmanaged Scheduled Task collision");
508
+ }
509
+ for (const artifact of artifacts) {
510
+ if (!fs.existsSync(artifact.path))
511
+ continue;
512
+ const existingDigest = digest(fs.readFileSync(artifact.path, "utf8"));
513
+ const managed = previous?.artifacts.some((record) => record.path === artifact.path && record.digest === existingDigest);
514
+ if (!managed)
515
+ throw new Error(`knodin backups retention: refusing unmanaged collision at ${artifact.path}`);
516
+ }
517
+ const policyContent = `${JSON.stringify(policy, null, 2)}\n`;
518
+ const receiptPath = path.join(stateRoot, "receipt.json");
519
+ const previousFiles = new Map();
520
+ for (const target of [policyPath, receiptPath, ...artifacts.map((artifact) => artifact.path)]) {
521
+ try {
522
+ previousFiles.set(target, fs.readFileSync(target, "utf8"));
523
+ }
524
+ catch (error) {
525
+ if (error.code !== "ENOENT")
526
+ throw error;
527
+ previousFiles.set(target, null);
528
+ }
529
+ }
530
+ atomicWrite(policyPath, policyContent);
531
+ for (const artifact of artifacts)
532
+ atomicWrite(artifact.path, artifact.content, artifact.mode);
533
+ const receipt = {
534
+ schemaVersion: 1,
535
+ platform,
536
+ installedAt: now.toISOString(),
537
+ policyDigest: digest(policyContent),
538
+ artifacts: artifacts.map((artifact) => ({
539
+ path: artifact.path,
540
+ digest: digest(artifact.content),
541
+ })),
542
+ };
543
+ atomicWrite(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
544
+ try {
545
+ schedulerRun(platform, "install", run, receipt.artifacts);
546
+ }
547
+ catch (error) {
548
+ for (const [target, content] of previousFiles) {
549
+ if (content === null)
550
+ fs.rmSync(target, { force: true });
551
+ else
552
+ atomicWrite(target, content);
553
+ }
554
+ if (previous) {
555
+ try {
556
+ schedulerRun(previous.platform, "install", run, previous.artifacts);
557
+ }
558
+ catch (rollbackError) {
559
+ throw new AggregateError([error, rollbackError], "knodin backups retention: install and rollback reload failed");
560
+ }
561
+ }
562
+ throw error;
563
+ }
564
+ return {
565
+ schemaVersion: 1,
566
+ installed: true,
567
+ policy,
568
+ receipt,
569
+ repositories: discovered.repositories,
570
+ };
571
+ }
572
+ export function retentionStatus(options = {}) {
573
+ const locations = homes(options);
574
+ const { policy, receipt, issues } = readInstalledRetention(options);
575
+ const lastRun = readJson(path.join(locations.stateRoot, "last-run.json"));
576
+ return {
577
+ schemaVersion: 1,
578
+ status: issues.length === 0 ? "healthy" : policy || receipt ? "attention-required" : "not-installed",
579
+ installed: Boolean(policy && receipt),
580
+ policy,
581
+ receipt,
582
+ lastRun,
583
+ issues,
584
+ remediation: issues.length
585
+ ? "Run `knodin backups retention doctor`, then reinstall or remove retention."
586
+ : null,
587
+ };
588
+ }
589
+ export function retentionDoctor(options = {}) {
590
+ const status = retentionStatus(options);
591
+ const platform = options.platform ?? process.platform;
592
+ const supported = ["darwin", "linux", "win32"].includes(platform);
593
+ const issues = [...status.issues];
594
+ const policy = status.policy;
595
+ const receipt = status.receipt;
596
+ if (policy && receipt) {
597
+ if (policy.roots.length === 0 ||
598
+ !Number.isInteger(policy.retentionDays) ||
599
+ !Number.isInteger(policy.keepNewest) ||
600
+ !Number.isInteger(policy.depth) ||
601
+ !policy.launcher[0] ||
602
+ !path.isAbsolute(policy.launcher[0]))
603
+ issues.push("policy-fields-invalid");
604
+ else {
605
+ try {
606
+ const executable = fs.lstatSync(policy.launcher[0]);
607
+ if (!executable.isFile() || executable.isSymbolicLink())
608
+ issues.push("launcher-unsafe");
609
+ if (platform !== "win32")
610
+ fs.accessSync(policy.launcher[0], fs.constants.X_OK);
611
+ }
612
+ catch {
613
+ issues.push("launcher-missing-or-not-executable");
614
+ }
615
+ }
616
+ for (const target of [
617
+ homes(options).policy,
618
+ path.join(homes(options).stateRoot, "receipt.json"),
619
+ ]) {
620
+ try {
621
+ if ((fs.statSync(target).mode & 0o077) !== 0)
622
+ issues.push(`permissions-too-open: ${target}`);
623
+ }
624
+ catch {
625
+ issues.push(`unreadable: ${target}`);
626
+ }
627
+ }
628
+ const installedAt = Date.parse(receipt.installedAt);
629
+ const ranAt = typeof status.lastRun?.ranAt === "string" ? Date.parse(status.lastRun.ranAt) : Number.NaN;
630
+ if (Date.now() - installedAt > 2 * DAY_MS &&
631
+ (!Number.isFinite(ranAt) || Date.now() - ranAt > 2 * DAY_MS))
632
+ issues.push("last-run-stale-or-missing");
633
+ const logs = path.join(homes(options).stateRoot, "logs");
634
+ try {
635
+ for (const name of fs.readdirSync(logs)) {
636
+ const target = path.join(logs, name);
637
+ if (fs.statSync(target).size > 5 * 1024 * 1024)
638
+ issues.push(`log-oversized: ${target}`);
639
+ for (const line of fs.readFileSync(target, "utf8").split("\n").filter(Boolean))
640
+ JSON.parse(line);
641
+ }
642
+ }
643
+ catch (error) {
644
+ if (error.code !== "ENOENT")
645
+ issues.push("logs-unreadable-or-invalid");
646
+ }
647
+ if (supported) {
648
+ const run = options.run ?? commandRunner;
649
+ let scheduler;
650
+ if (platform === "darwin")
651
+ scheduler = run("launchctl", [
652
+ "print",
653
+ `gui/${typeof process.getuid === "function" ? process.getuid() : 0}/dev.knodin.backup-retention`,
654
+ ]);
655
+ else if (platform === "linux")
656
+ scheduler = run("systemctl", ["--user", "is-enabled", "knodin-backup-retention.timer"]);
657
+ else
658
+ scheduler = run("schtasks", ["/Query", "/TN", "Knodin Backup Retention", "/XML"]);
659
+ if (scheduler.status !== 0)
660
+ issues.push("scheduler-not-loaded-or-enabled");
661
+ }
662
+ }
663
+ return {
664
+ ...status,
665
+ status: issues.length === 0 ? status.status : "attention-required",
666
+ issues,
667
+ platform,
668
+ schedulerSupported: supported,
669
+ remediation: supported
670
+ ? issues.length
671
+ ? "Repair the reported policy, launcher, scheduler, permission, run, or log issue; then reinstall if needed."
672
+ : null
673
+ : "Scheduled retention is unsupported here; one-shot preview/apply pruning remains available.",
674
+ };
675
+ }
676
+ export function removeBackupRetention(options = {}) {
677
+ const locations = homes(options);
678
+ const receiptPath = path.join(locations.stateRoot, "receipt.json");
679
+ const installed = readInstalledRetention(options);
680
+ if (!installed.policy || !installed.receipt)
681
+ return { schemaVersion: 1, removed: false, reason: "not-installed" };
682
+ if (installed.issues.length > 0)
683
+ return {
684
+ schemaVersion: 1,
685
+ removed: false,
686
+ reason: "unhealthy-installation",
687
+ issues: installed.issues,
688
+ };
689
+ const receipt = installed.receipt;
690
+ const expected = schedulerArtifacts(installed.policy, {
691
+ ...options,
692
+ platform: receipt.platform,
693
+ }).map((artifact) => ({ path: artifact.path, digest: digest(artifact.content) }));
694
+ if (receipt.artifacts.length !== expected.length ||
695
+ receipt.artifacts.some((artifact, index) => artifact.path !== expected[index]?.path || artifact.digest !== expected[index]?.digest))
696
+ return { schemaVersion: 1, removed: false, reason: "unmanaged-artifact-receipt" };
697
+ const skipped = [];
698
+ for (const artifact of receipt.artifacts) {
699
+ try {
700
+ if (digest(fs.readFileSync(artifact.path, "utf8")) !== artifact.digest)
701
+ skipped.push(artifact.path);
702
+ }
703
+ catch (error) {
704
+ if (error.code !== "ENOENT")
705
+ skipped.push(artifact.path);
706
+ }
707
+ }
708
+ if (skipped.length > 0)
709
+ return { schemaVersion: 1, removed: false, skipped };
710
+ const run = options.run ?? commandRunner;
711
+ if (receipt.platform === "win32") {
712
+ const registered = run("schtasks", ["/Query", "/TN", "Knodin Backup Retention", "/XML"]);
713
+ if (registered.status === 0)
714
+ schedulerRun(receipt.platform, "remove", run, receipt.artifacts);
715
+ }
716
+ else {
717
+ schedulerRun(receipt.platform, "remove", run, receipt.artifacts);
718
+ }
719
+ for (const artifact of receipt.artifacts) {
720
+ try {
721
+ if (digest(fs.readFileSync(artifact.path, "utf8")) === artifact.digest)
722
+ fs.unlinkSync(artifact.path);
723
+ }
724
+ catch (error) {
725
+ if (error.code !== "ENOENT")
726
+ skipped.push(artifact.path);
727
+ }
728
+ }
729
+ if (skipped.length === 0) {
730
+ fs.rmSync(locations.policy, { force: true });
731
+ fs.rmSync(receiptPath, { force: true });
732
+ }
733
+ return { schemaVersion: 1, removed: skipped.length === 0, skipped };
734
+ }
735
+ export function runInstalledRetention(options = {}) {
736
+ const locations = homes(options);
737
+ const installed = readInstalledRetention(options);
738
+ if (!installed.policy || !installed.receipt)
739
+ throw new Error("knodin backups retention: not installed");
740
+ if (installed.issues.length > 0)
741
+ throw new Error(`knodin backups retention: refusing unhealthy installation (${installed.issues.join(", ")})`);
742
+ const policy = installed.policy;
743
+ const result = pruneBackups(policy.roots, {
744
+ ...options,
745
+ depth: policy.depth,
746
+ includeRegistry: true,
747
+ retentionDays: policy.retentionDays,
748
+ keepNewest: policy.keepNewest,
749
+ apply: !policy.dryRun,
750
+ });
751
+ const record = { ...result, ranAt: (options.now ?? new Date()).toISOString() };
752
+ atomicWrite(path.join(locations.stateRoot, "last-run.json"), `${JSON.stringify(record, null, 2)}\n`);
753
+ appendRunLog(locations.stateRoot, record);
754
+ return record;
755
+ }
756
+ function appendRunLog(stateRoot, record) {
757
+ fs.mkdirSync(path.join(stateRoot, "logs"), { recursive: true, mode: 0o700 });
758
+ const now = new Date();
759
+ const cutoff = now.getTime() - 30 * DAY_MS;
760
+ for (const name of fs.readdirSync(path.join(stateRoot, "logs"))) {
761
+ const target = path.join(stateRoot, "logs", name);
762
+ try {
763
+ if (fs.statSync(target).mtimeMs < cutoff)
764
+ fs.unlinkSync(target);
765
+ }
766
+ catch { }
767
+ }
768
+ const target = path.join(stateRoot, "logs", `${now.toISOString().slice(0, 10)}.jsonl`);
769
+ fs.appendFileSync(target, `${JSON.stringify(record)}\n`, { mode: 0o600 });
770
+ if (fs.statSync(target).size > 5 * 1024 * 1024) {
771
+ const lines = fs.readFileSync(target, "utf8").trimEnd().split("\n");
772
+ const retained = [];
773
+ let bytes = 0;
774
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
775
+ const line = lines[index];
776
+ const lineBytes = Buffer.byteLength(line) + 1;
777
+ if (bytes + lineBytes > 5 * 1024 * 1024)
778
+ break;
779
+ retained.unshift(line);
780
+ bytes += lineBytes;
781
+ }
782
+ fs.writeFileSync(target, retained.length ? `${retained.join("\n")}\n` : "", { mode: 0o600 });
783
+ }
784
+ }
785
+ export function maybeRunOpportunisticRetention(repository, options = {}) {
786
+ const locations = homes(options);
787
+ const installed = readInstalledRetention(options);
788
+ if (!installed.policy || !installed.receipt)
789
+ return { status: "disabled" };
790
+ if (installed.issues.length > 0)
791
+ return { status: "attention-required", issues: installed.issues };
792
+ const policy = installed.policy;
793
+ const duePath = path.join(locations.stateRoot, "due", `${digest(path.resolve(repository))}.json`);
794
+ const due = readJson(duePath);
795
+ const now = options.now ?? new Date();
796
+ if (due?.ranAt && now.getTime() - Date.parse(due.ranAt) < DAY_MS)
797
+ return { status: "not-due" };
798
+ const result = pruneBackups([repository], {
799
+ ...options,
800
+ depth: 0,
801
+ includeRegistry: false,
802
+ retentionDays: policy.retentionDays,
803
+ keepNewest: policy.keepNewest,
804
+ apply: !policy.dryRun,
805
+ });
806
+ atomicWrite(duePath, `${JSON.stringify({ schemaVersion: 1, ranAt: now.toISOString() })}\n`);
807
+ return { status: "ran", result };
808
+ }
809
+ export function formatBackupHuman(label, result) {
810
+ return `${label}\n${JSON.stringify(result, null, 2)}\n`;
811
+ }