@yoavsh/treekeeper 0.0.0-stage → 0.1.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 (166) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +237 -2
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +165 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/commands/archive.d.ts +3 -0
  9. package/dist/commands/archive.d.ts.map +1 -0
  10. package/dist/commands/archive.js +55 -0
  11. package/dist/commands/archive.js.map +1 -0
  12. package/dist/commands/config.d.ts +2 -0
  13. package/dist/commands/config.d.ts.map +1 -0
  14. package/dist/commands/config.js +54 -0
  15. package/dist/commands/config.js.map +1 -0
  16. package/dist/commands/context.d.ts +34 -0
  17. package/dist/commands/context.d.ts.map +1 -0
  18. package/dist/commands/context.js +53 -0
  19. package/dist/commands/context.js.map +1 -0
  20. package/dist/commands/daemon.d.ts +3 -0
  21. package/dist/commands/daemon.d.ts.map +1 -0
  22. package/dist/commands/daemon.js +81 -0
  23. package/dist/commands/daemon.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +3 -0
  25. package/dist/commands/doctor.d.ts.map +1 -0
  26. package/dist/commands/doctor.js +119 -0
  27. package/dist/commands/doctor.js.map +1 -0
  28. package/dist/commands/gc.d.ts +3 -0
  29. package/dist/commands/gc.d.ts.map +1 -0
  30. package/dist/commands/gc.js +98 -0
  31. package/dist/commands/gc.js.map +1 -0
  32. package/dist/commands/status.d.ts +4 -0
  33. package/dist/commands/status.d.ts.map +1 -0
  34. package/dist/commands/status.js +52 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/watch.d.ts +10 -0
  37. package/dist/commands/watch.d.ts.map +1 -0
  38. package/dist/commands/watch.js +41 -0
  39. package/dist/commands/watch.js.map +1 -0
  40. package/dist/core/apply.d.ts +47 -0
  41. package/dist/core/apply.d.ts.map +1 -0
  42. package/dist/core/apply.js +223 -0
  43. package/dist/core/apply.js.map +1 -0
  44. package/dist/core/archive.d.ts +69 -0
  45. package/dist/core/archive.d.ts.map +1 -0
  46. package/dist/core/archive.js +202 -0
  47. package/dist/core/archive.js.map +1 -0
  48. package/dist/core/config.d.ts +69 -0
  49. package/dist/core/config.d.ts.map +1 -0
  50. package/dist/core/config.js +261 -0
  51. package/dist/core/config.js.map +1 -0
  52. package/dist/core/decide.d.ts +102 -0
  53. package/dist/core/decide.d.ts.map +1 -0
  54. package/dist/core/decide.js +148 -0
  55. package/dist/core/decide.js.map +1 -0
  56. package/dist/core/forge.d.ts +39 -0
  57. package/dist/core/forge.d.ts.map +1 -0
  58. package/dist/core/forge.js +190 -0
  59. package/dist/core/forge.js.map +1 -0
  60. package/dist/core/git.d.ts +63 -0
  61. package/dist/core/git.d.ts.map +1 -0
  62. package/dist/core/git.js +194 -0
  63. package/dist/core/git.js.map +1 -0
  64. package/dist/core/plan.d.ts +75 -0
  65. package/dist/core/plan.d.ts.map +1 -0
  66. package/dist/core/plan.js +288 -0
  67. package/dist/core/plan.js.map +1 -0
  68. package/dist/core/render.d.ts +14 -0
  69. package/dist/core/render.d.ts.map +1 -0
  70. package/dist/core/render.js +128 -0
  71. package/dist/core/render.js.map +1 -0
  72. package/dist/core/scan.d.ts +22 -0
  73. package/dist/core/scan.d.ts.map +1 -0
  74. package/dist/core/scan.js +99 -0
  75. package/dist/core/scan.js.map +1 -0
  76. package/dist/core/sessions.d.ts +37 -0
  77. package/dist/core/sessions.d.ts.map +1 -0
  78. package/dist/core/sessions.js +300 -0
  79. package/dist/core/sessions.js.map +1 -0
  80. package/dist/core/size.d.ts +3 -0
  81. package/dist/core/size.d.ts.map +1 -0
  82. package/dist/core/size.js +11 -0
  83. package/dist/core/size.js.map +1 -0
  84. package/dist/daemon/lock.d.ts +10 -0
  85. package/dist/daemon/lock.d.ts.map +1 -0
  86. package/dist/daemon/lock.js +64 -0
  87. package/dist/daemon/lock.js.map +1 -0
  88. package/dist/daemon/service.d.ts +34 -0
  89. package/dist/daemon/service.d.ts.map +1 -0
  90. package/dist/daemon/service.js +257 -0
  91. package/dist/daemon/service.js.map +1 -0
  92. package/dist/daemon/state.d.ts +19 -0
  93. package/dist/daemon/state.d.ts.map +1 -0
  94. package/dist/daemon/state.js +32 -0
  95. package/dist/daemon/state.js.map +1 -0
  96. package/dist/index.d.ts +26 -0
  97. package/dist/index.d.ts.map +1 -0
  98. package/dist/index.js +19 -0
  99. package/dist/index.js.map +1 -0
  100. package/dist/util/bytes.d.ts +4 -0
  101. package/dist/util/bytes.d.ts.map +1 -0
  102. package/dist/util/bytes.js +14 -0
  103. package/dist/util/bytes.js.map +1 -0
  104. package/dist/util/color.d.ts +16 -0
  105. package/dist/util/color.d.ts.map +1 -0
  106. package/dist/util/color.js +32 -0
  107. package/dist/util/color.js.map +1 -0
  108. package/dist/util/exec.d.ts +27 -0
  109. package/dist/util/exec.d.ts.map +1 -0
  110. package/dist/util/exec.js +57 -0
  111. package/dist/util/exec.js.map +1 -0
  112. package/dist/util/log.d.ts +29 -0
  113. package/dist/util/log.d.ts.map +1 -0
  114. package/dist/util/log.js +72 -0
  115. package/dist/util/log.js.map +1 -0
  116. package/dist/util/paths.d.ts +18 -0
  117. package/dist/util/paths.d.ts.map +1 -0
  118. package/dist/util/paths.js +87 -0
  119. package/dist/util/paths.js.map +1 -0
  120. package/dist/util/prompt.d.ts +3 -0
  121. package/dist/util/prompt.d.ts.map +1 -0
  122. package/dist/util/prompt.js +15 -0
  123. package/dist/util/prompt.js.map +1 -0
  124. package/dist/util/redact.d.ts +15 -0
  125. package/dist/util/redact.d.ts.map +1 -0
  126. package/dist/util/redact.js +44 -0
  127. package/dist/util/redact.js.map +1 -0
  128. package/dist/util/time.d.ts +13 -0
  129. package/dist/util/time.d.ts.map +1 -0
  130. package/dist/util/time.js +77 -0
  131. package/dist/util/time.js.map +1 -0
  132. package/docs/integrations.md +128 -0
  133. package/docs/safety.md +62 -0
  134. package/package.json +69 -4
  135. package/src/cli.ts +175 -0
  136. package/src/commands/archive.ts +72 -0
  137. package/src/commands/config.ts +57 -0
  138. package/src/commands/context.ts +86 -0
  139. package/src/commands/daemon.ts +86 -0
  140. package/src/commands/doctor.ts +131 -0
  141. package/src/commands/gc.ts +108 -0
  142. package/src/commands/status.ts +68 -0
  143. package/src/commands/watch.ts +48 -0
  144. package/src/core/apply.ts +276 -0
  145. package/src/core/archive.ts +269 -0
  146. package/src/core/config.ts +339 -0
  147. package/src/core/decide.ts +230 -0
  148. package/src/core/forge.ts +243 -0
  149. package/src/core/git.ts +254 -0
  150. package/src/core/plan.ts +431 -0
  151. package/src/core/render.ts +168 -0
  152. package/src/core/scan.ts +105 -0
  153. package/src/core/sessions.ts +339 -0
  154. package/src/core/size.ts +10 -0
  155. package/src/daemon/lock.ts +65 -0
  156. package/src/daemon/service.ts +321 -0
  157. package/src/daemon/state.ts +49 -0
  158. package/src/index.ts +36 -0
  159. package/src/util/bytes.ts +11 -0
  160. package/src/util/color.ts +33 -0
  161. package/src/util/exec.ts +80 -0
  162. package/src/util/log.ts +88 -0
  163. package/src/util/paths.ts +84 -0
  164. package/src/util/prompt.ts +13 -0
  165. package/src/util/redact.ts +43 -0
  166. package/src/util/time.ts +75 -0
@@ -0,0 +1,321 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { run } from "../util/exec.js";
6
+ import { treekeeperHome } from "../util/paths.js";
7
+ import { parseDuration } from "../util/time.js";
8
+ import type { SweepRecord } from "./state.js";
9
+ import { readState } from "./state.js";
10
+
11
+ export const LABEL = "com.treekeeper.agent";
12
+ export const SYSTEMD_UNIT = "treekeeper";
13
+
14
+ export type Manager = "launchd" | "systemd" | "unsupported";
15
+
16
+ export interface DaemonStatus {
17
+ manager: Manager;
18
+ installed: boolean;
19
+ /** Loaded with the service manager and scheduled to run. */
20
+ active: boolean;
21
+ unitPath: string | null;
22
+ intervalSeconds: number | null;
23
+ lastSweep: SweepRecord | null;
24
+ logFile: string;
25
+ }
26
+
27
+ export function currentManager(): Manager {
28
+ if (process.platform === "darwin") return "launchd";
29
+ if (process.platform === "linux") return "systemd";
30
+ return "unsupported";
31
+ }
32
+
33
+ function launchAgentPath(): string {
34
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
35
+ }
36
+
37
+ function systemdDir(): string {
38
+ const base = process.env.XDG_CONFIG_HOME
39
+ ? resolve(process.env.XDG_CONFIG_HOME)
40
+ : join(homedir(), ".config");
41
+ return join(base, "systemd", "user");
42
+ }
43
+
44
+ function daemonLogPath(): string {
45
+ return join(treekeeperHome(), "daemon.out.log");
46
+ }
47
+
48
+ /** Absolute path to this installation's CLI entry point. */
49
+ export function cliPath(): string {
50
+ return resolve(fileURLToPath(new URL("../cli.js", import.meta.url)));
51
+ }
52
+
53
+ /** The command a scheduled sweep runs. */
54
+ export function sweepArgv(extra: string[] = []): string[] {
55
+ return [process.execPath, cliPath(), "gc", "--all", "--apply", "--yes", "--quiet", ...extra];
56
+ }
57
+
58
+ export async function daemonStatus(): Promise<DaemonStatus> {
59
+ const manager = currentManager();
60
+ const state = await readState();
61
+ const base: DaemonStatus = {
62
+ manager,
63
+ installed: false,
64
+ active: false,
65
+ unitPath: null,
66
+ intervalSeconds: null,
67
+ lastSweep: state.lastSweep,
68
+ logFile: daemonLogPath(),
69
+ };
70
+
71
+ if (manager === "launchd") {
72
+ const path = launchAgentPath();
73
+ const plist = await readIfPresent(path);
74
+ base.unitPath = path;
75
+ base.installed = plist !== null;
76
+ if (plist) {
77
+ const m = /<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/.exec(plist);
78
+ base.intervalSeconds = m?.[1] ? Number(m[1]) : null;
79
+ const list = await run("launchctl", ["list", LABEL], { timeoutMs: 10_000 });
80
+ base.active = list.code === 0;
81
+ }
82
+ return base;
83
+ }
84
+
85
+ if (manager === "systemd") {
86
+ const timer = join(systemdDir(), `${SYSTEMD_UNIT}.timer`);
87
+ const unit = await readIfPresent(timer);
88
+ base.unitPath = timer;
89
+ base.installed = unit !== null;
90
+ if (unit) {
91
+ const m = /OnUnitActiveSec=(\d+)/.exec(unit);
92
+ base.intervalSeconds = m?.[1] ? Number(m[1]) : null;
93
+ const active = await run("systemctl", ["--user", "is-active", `${SYSTEMD_UNIT}.timer`], {
94
+ timeoutMs: 10_000,
95
+ });
96
+ base.active = active.stdout.trim() === "active";
97
+ }
98
+ return base;
99
+ }
100
+
101
+ return base;
102
+ }
103
+
104
+ export interface InstallResult {
105
+ ok: boolean;
106
+ manager: Manager;
107
+ unitPath: string | null;
108
+ messages: string[];
109
+ }
110
+
111
+ /** Write and load the service definition for the current platform. */
112
+ export async function installDaemon(interval: string): Promise<InstallResult> {
113
+ const manager = currentManager();
114
+ const seconds = Math.max(60, Math.round(parseDuration(interval) / 1000));
115
+ const messages: string[] = [];
116
+
117
+ if (manager === "launchd") {
118
+ const path = launchAgentPath();
119
+ await mkdir(dirname(path), { recursive: true });
120
+ await mkdir(treekeeperHome(), { recursive: true });
121
+ await writeFile(path, launchdPlist(seconds));
122
+
123
+ // bootout first so a re-install picks up a changed interval.
124
+ await run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${LABEL}`], { timeoutMs: 15_000 });
125
+ const boot = await run(
126
+ "launchctl",
127
+ ["bootstrap", `gui/${process.getuid?.() ?? 501}`, path],
128
+ { timeoutMs: 20_000 },
129
+ );
130
+ if (boot.code !== 0) {
131
+ const legacy = await run("launchctl", ["load", "-w", path], { timeoutMs: 20_000 });
132
+ if (legacy.code !== 0) {
133
+ messages.push(`wrote ${path} but launchctl could not load it: ${(boot.stderr || legacy.stderr).trim()}`);
134
+ return { ok: false, manager, unitPath: path, messages };
135
+ }
136
+ }
137
+ messages.push(`installed ${path}`, `sweeping every ${seconds}s · logs in ${daemonLogPath()}`);
138
+ return { ok: true, manager, unitPath: path, messages };
139
+ }
140
+
141
+ if (manager === "systemd") {
142
+ const dir = systemdDir();
143
+ await mkdir(dir, { recursive: true });
144
+ await mkdir(treekeeperHome(), { recursive: true });
145
+ const servicePath = join(dir, `${SYSTEMD_UNIT}.service`);
146
+ const timerPath = join(dir, `${SYSTEMD_UNIT}.timer`);
147
+ await writeFile(servicePath, systemdService());
148
+ await writeFile(timerPath, systemdTimer(seconds));
149
+
150
+ await run("systemctl", ["--user", "daemon-reload"], { timeoutMs: 20_000 });
151
+ const enable = await run(
152
+ "systemctl",
153
+ ["--user", "enable", "--now", `${SYSTEMD_UNIT}.timer`],
154
+ { timeoutMs: 20_000 },
155
+ );
156
+ if (enable.code !== 0) {
157
+ messages.push(`wrote the units but systemctl failed: ${enable.stderr.trim()}`);
158
+ return { ok: false, manager, unitPath: timerPath, messages };
159
+ }
160
+ messages.push(
161
+ `installed ${servicePath} and ${timerPath}`,
162
+ `sweeping every ${seconds}s`,
163
+ "tip: `loginctl enable-linger $USER` keeps the timer running while you are logged out",
164
+ );
165
+ return { ok: true, manager, unitPath: timerPath, messages };
166
+ }
167
+
168
+ messages.push(
169
+ `no supported service manager on ${process.platform}.`,
170
+ "Run `treekeeper watch` under your own supervisor, or schedule `treekeeper gc --all --apply --yes`.",
171
+ );
172
+ return { ok: false, manager, unitPath: null, messages };
173
+ }
174
+
175
+ export async function uninstallDaemon(): Promise<InstallResult> {
176
+ const manager = currentManager();
177
+ const messages: string[] = [];
178
+
179
+ if (manager === "launchd") {
180
+ const path = launchAgentPath();
181
+ await run("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}/${LABEL}`], { timeoutMs: 15_000 });
182
+ await run("launchctl", ["unload", "-w", path], { timeoutMs: 15_000 });
183
+ await rm(path, { force: true });
184
+ messages.push(`removed ${path}`);
185
+ return { ok: true, manager, unitPath: path, messages };
186
+ }
187
+
188
+ if (manager === "systemd") {
189
+ const dir = systemdDir();
190
+ await run("systemctl", ["--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`], { timeoutMs: 20_000 });
191
+ await rm(join(dir, `${SYSTEMD_UNIT}.timer`), { force: true });
192
+ await rm(join(dir, `${SYSTEMD_UNIT}.service`), { force: true });
193
+ await run("systemctl", ["--user", "daemon-reload"], { timeoutMs: 20_000 });
194
+ messages.push("removed the treekeeper timer and service");
195
+ return { ok: true, manager, unitPath: null, messages };
196
+ }
197
+
198
+ messages.push("nothing to uninstall");
199
+ return { ok: true, manager, unitPath: null, messages };
200
+ }
201
+
202
+ export async function setDaemonRunning(running: boolean): Promise<InstallResult> {
203
+ const manager = currentManager();
204
+ const messages: string[] = [];
205
+ if (manager === "launchd") {
206
+ const path = launchAgentPath();
207
+ const uid = process.getuid?.() ?? 501;
208
+ const r = running
209
+ ? await run("launchctl", ["bootstrap", `gui/${uid}`, path], { timeoutMs: 20_000 })
210
+ : await run("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { timeoutMs: 20_000 });
211
+ messages.push(r.code === 0 ? (running ? "started" : "stopped") : (r.stderr || r.stdout).trim());
212
+ return { ok: r.code === 0, manager, unitPath: path, messages };
213
+ }
214
+ if (manager === "systemd") {
215
+ const r = await run(
216
+ "systemctl",
217
+ ["--user", running ? "start" : "stop", `${SYSTEMD_UNIT}.timer`],
218
+ { timeoutMs: 20_000 },
219
+ );
220
+ messages.push(r.code === 0 ? (running ? "started" : "stopped") : r.stderr.trim());
221
+ return { ok: r.code === 0, manager, unitPath: null, messages };
222
+ }
223
+ messages.push(`not supported on ${process.platform}`);
224
+ return { ok: false, manager, unitPath: null, messages };
225
+ }
226
+
227
+ export function launchdPlist(seconds: number): string {
228
+ const args = sweepArgv()
229
+ .map((a) => ` <string>${escapeXml(a)}</string>`)
230
+ .join("\n");
231
+ const log = escapeXml(daemonLogPath());
232
+ return `<?xml version="1.0" encoding="UTF-8"?>
233
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
234
+ <plist version="1.0">
235
+ <dict>
236
+ <key>Label</key>
237
+ <string>${LABEL}</string>
238
+ <key>ProgramArguments</key>
239
+ <array>
240
+ ${args}
241
+ </array>
242
+ <key>StartInterval</key>
243
+ <integer>${seconds}</integer>
244
+ <key>RunAtLoad</key>
245
+ <true/>
246
+ <key>LowPriorityIO</key>
247
+ <true/>
248
+ <key>Nice</key>
249
+ <integer>5</integer>
250
+ <key>ProcessType</key>
251
+ <string>Background</string>
252
+ <key>StandardOutPath</key>
253
+ <string>${log}</string>
254
+ <key>StandardErrorPath</key>
255
+ <string>${log}</string>
256
+ <key>EnvironmentVariables</key>
257
+ <dict>
258
+ <key>PATH</key>
259
+ <string>${escapeXml(schedulerPath())}</string>
260
+ </dict>
261
+ </dict>
262
+ </plist>
263
+ `;
264
+ }
265
+
266
+ export function systemdService(): string {
267
+ const [exe, ...rest] = sweepArgv();
268
+ return `[Unit]
269
+ Description=treekeeper — reclaim disk from finished agent worktrees
270
+ Documentation=https://github.com/yoavshmariahu/treekeeper
271
+
272
+ [Service]
273
+ Type=oneshot
274
+ Nice=5
275
+ IOSchedulingClass=idle
276
+ Environment=PATH=${schedulerPath()}
277
+ ExecStart=${exe} ${rest.join(" ")}
278
+ `;
279
+ }
280
+
281
+ export function systemdTimer(seconds: number): string {
282
+ return `[Unit]
283
+ Description=treekeeper periodic sweep
284
+
285
+ [Timer]
286
+ OnBootSec=300
287
+ OnUnitActiveSec=${seconds}
288
+ AccuracySec=60
289
+ Persistent=true
290
+
291
+ [Install]
292
+ WantedBy=timers.target
293
+ `;
294
+ }
295
+
296
+ /**
297
+ * Service managers start jobs with a minimal PATH, which would hide `git` and
298
+ * `gh`. Carry the installing shell's PATH plus the usual suspects.
299
+ */
300
+ function schedulerPath(): string {
301
+ const extra = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
302
+ const current = (process.env.PATH ?? "").split(":").filter((p) => p !== "");
303
+ const seen = new Set<string>();
304
+ return [...current, ...extra].filter((p) => (seen.has(p) ? false : (seen.add(p), true))).join(":");
305
+ }
306
+
307
+ function escapeXml(s: string): string {
308
+ return s
309
+ .replaceAll("&", "&amp;")
310
+ .replaceAll("<", "&lt;")
311
+ .replaceAll(">", "&gt;")
312
+ .replaceAll('"', "&quot;");
313
+ }
314
+
315
+ async function readIfPresent(path: string): Promise<string | null> {
316
+ try {
317
+ return await readFile(path, "utf8");
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
@@ -0,0 +1,49 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { treekeeperHome } from "../util/paths.js";
4
+
5
+ export interface SweepRecord {
6
+ at: number;
7
+ durationMs: number;
8
+ repos: number;
9
+ worktrees: number;
10
+ branches: number;
11
+ reclaimedKb: number;
12
+ failures: number;
13
+ }
14
+
15
+ export interface State {
16
+ version: 1;
17
+ lastSweep: SweepRecord | null;
18
+ sweeps: number;
19
+ totalReclaimedKb: number;
20
+ }
21
+
22
+ export function statePath(): string {
23
+ return join(treekeeperHome(), "state.json");
24
+ }
25
+
26
+ export async function readState(): Promise<State> {
27
+ try {
28
+ const parsed: unknown = JSON.parse(await readFile(statePath(), "utf8"));
29
+ if (parsed && typeof parsed === "object" && (parsed as State).version === 1) {
30
+ return parsed as State;
31
+ }
32
+ } catch {
33
+ /* first run */
34
+ }
35
+ return { version: 1, lastSweep: null, sweeps: 0, totalReclaimedKb: 0 };
36
+ }
37
+
38
+ export async function recordSweep(sweep: SweepRecord): Promise<void> {
39
+ const state = await readState();
40
+ state.lastSweep = sweep;
41
+ state.sweeps += 1;
42
+ state.totalReclaimedKb += sweep.reclaimedKb;
43
+ try {
44
+ await mkdir(treekeeperHome(), { recursive: true });
45
+ await writeFile(statePath(), JSON.stringify(state, null, 2) + "\n");
46
+ } catch {
47
+ /* a missing state file only costs a status line */
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Programmatic entry point, for harnesses that would rather call treekeeper
3
+ * in-process than shell out to the CLI.
4
+ *
5
+ * import { loadConfig, createForge, discoverRepos, buildPlan, applyPlan } from "@yoavsh/treekeeper";
6
+ */
7
+ export { loadConfig, loadRepoConfig, defaultConfig, globalConfigPath } from "./core/config.js";
8
+ export type { Config, SessionConfig, ArchiveConfig, DirtyPolicy } from "./core/config.js";
9
+
10
+ export { buildPlan, policyFromConfig, reclaimable, mergeStatus } from "./core/plan.js";
11
+ export type {
12
+ Plan, PlanOptions, PlanSummary, RepoPlan, WorktreeCandidate, BranchCandidate,
13
+ } from "./core/plan.js";
14
+
15
+ export { decideWorktree, decideBranch } from "./core/decide.js";
16
+ export type {
17
+ Verdict, Policy, WorktreeFacts, BranchFacts, MergeStatus, Risk,
18
+ } from "./core/decide.js";
19
+
20
+ export { applyPlan } from "./core/apply.js";
21
+ export type { ApplyOptions, ApplyResult, Removal, Failure } from "./core/apply.js";
22
+
23
+ export { archiveWork, listArchives, pruneArchives } from "./core/archive.js";
24
+ export type { ArchiveRecord } from "./core/archive.js";
25
+
26
+ export { createForge, selectPr } from "./core/forge.js";
27
+ export type { Forge, PrRef, PrIndex, PrState } from "./core/forge.js";
28
+
29
+ export { discoverRepos, reposFromPaths } from "./core/scan.js";
30
+ export { createActivityContext, detectActivity, isActive } from "./core/sessions.js";
31
+ export type { ActivityContext, ActivityHit } from "./core/sessions.js";
32
+
33
+ export { renderPlan, renderApply, summaryLine } from "./core/render.js";
34
+ export { Logger } from "./util/log.js";
35
+ export { parseDuration, formatDuration } from "./util/time.js";
36
+ export { main as cli } from "./cli.js";
@@ -0,0 +1,11 @@
1
+ /** Kilobytes (as reported by `du -sk`) to a short human string. */
2
+ export function formatKb(kb: number): string {
3
+ if (!Number.isFinite(kb) || kb < 0) return "?";
4
+ if (kb >= 1024 * 1024) return `${(kb / (1024 * 1024)).toFixed(1)}G`;
5
+ if (kb >= 1024) return `${Math.round(kb / 1024)}M`;
6
+ return `${Math.round(kb)}K`;
7
+ }
8
+
9
+ export function formatBytes(bytes: number): string {
10
+ return formatKb(bytes / 1024);
11
+ }
@@ -0,0 +1,33 @@
1
+ const enabled = (() => {
2
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== "") return false;
3
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== "0") return true;
4
+ if (process.env.TERM === "dumb") return false;
5
+ return Boolean(process.stdout.isTTY);
6
+ })();
7
+
8
+ const wrap = (open: string, close = "\u001b[0m") => (s: string | number) =>
9
+ enabled ? `${open}${s}${close}` : String(s);
10
+
11
+ export const color = {
12
+ enabled,
13
+ bold: wrap("\u001b[1m"),
14
+ dim: wrap("\u001b[2m"),
15
+ red: wrap("\u001b[31m"),
16
+ green: wrap("\u001b[32m"),
17
+ yellow: wrap("\u001b[33m"),
18
+ blue: wrap("\u001b[34m"),
19
+ magenta: wrap("\u001b[35m"),
20
+ cyan: wrap("\u001b[36m"),
21
+ gray: wrap("\u001b[90m"),
22
+ };
23
+
24
+ /** Visible width, ignoring ANSI escapes, so columns line up when colour is on. */
25
+ export function visibleWidth(s: string): number {
26
+ // eslint-disable-next-line no-control-regex
27
+ return s.replace(/\u001b\[[0-9;]*m/g, "").length;
28
+ }
29
+
30
+ export function padEndVisible(s: string, width: number): string {
31
+ const pad = width - visibleWidth(s);
32
+ return pad > 0 ? s + " ".repeat(pad) : s;
33
+ }
@@ -0,0 +1,80 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ export interface RunResult {
4
+ code: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ /** True when the binary itself could not be launched (not installed, EACCES, timeout). */
8
+ failedToStart: boolean;
9
+ }
10
+
11
+ export interface RunOptions {
12
+ cwd?: string;
13
+ timeoutMs?: number;
14
+ env?: NodeJS.ProcessEnv;
15
+ maxBuffer?: number;
16
+ input?: string;
17
+ }
18
+
19
+ /**
20
+ * Run a command without ever throwing. Every caller in treekeeper has to make a
21
+ * decision from the exit code (a missing `gh`, a repo without a remote, a branch
22
+ * that vanished), so an exception-based API would just mean try/catch everywhere.
23
+ */
24
+ export function run(cmd: string, args: string[], opts: RunOptions = {}): Promise<RunResult> {
25
+ return new Promise((resolve) => {
26
+ const child = execFile(
27
+ cmd,
28
+ args,
29
+ {
30
+ cwd: opts.cwd,
31
+ timeout: opts.timeoutMs ?? 60_000,
32
+ maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
33
+ env: opts.env ?? process.env,
34
+ encoding: "utf8",
35
+ },
36
+ (err, stdout, stderr) => {
37
+ if (!err) {
38
+ resolve({ code: 0, stdout, stderr, failedToStart: false });
39
+ return;
40
+ }
41
+ const code = typeof err.code === "number" ? err.code : null;
42
+ resolve({
43
+ code: code ?? 127,
44
+ stdout: stdout ?? "",
45
+ stderr: stderr ?? String(err.message ?? ""),
46
+ failedToStart: code === null,
47
+ });
48
+ },
49
+ );
50
+ if (opts.input !== undefined) {
51
+ child.stdin?.end(opts.input);
52
+ }
53
+ });
54
+ }
55
+
56
+ /** stdout trimmed on success, `null` on any failure. */
57
+ export async function runOk(cmd: string, args: string[], opts: RunOptions = {}): Promise<string | null> {
58
+ const r = await run(cmd, args, opts);
59
+ return r.code === 0 ? r.stdout.trim() : null;
60
+ }
61
+
62
+ /** stdout split into non-empty trimmed lines; `[]` on failure. */
63
+ export async function runLines(cmd: string, args: string[], opts: RunOptions = {}): Promise<string[]> {
64
+ const out = await runOk(cmd, args, opts);
65
+ if (out === null || out === "") return [];
66
+ return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
67
+ }
68
+
69
+ const haveCache = new Map<string, boolean>();
70
+
71
+ /** Whether an executable is on PATH. Cached for the life of the process. */
72
+ export async function have(cmd: string): Promise<boolean> {
73
+ const cached = haveCache.get(cmd);
74
+ if (cached !== undefined) return cached;
75
+ const probe = process.platform === "win32" ? "where" : "which";
76
+ const r = await run(probe, [cmd], { timeoutMs: 5_000 });
77
+ const ok = r.code === 0 && r.stdout.trim().length > 0;
78
+ haveCache.set(cmd, ok);
79
+ return ok;
80
+ }
@@ -0,0 +1,88 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { LogLevel } from "../core/config.js";
4
+ import { color } from "./color.js";
5
+ import { redactSecrets, redactValue } from "./redact.js";
6
+ import { formatClock } from "./time.js";
7
+
8
+ const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
9
+
10
+ export type LogFields = Record<string, unknown>;
11
+
12
+ export interface LoggerOptions {
13
+ level?: LogLevel;
14
+ /** JSONL destination. Omit to log to the console only. */
15
+ file?: string;
16
+ /** Suppress console output (the log file still receives everything). */
17
+ quiet?: boolean;
18
+ }
19
+
20
+ export class Logger {
21
+ readonly level: LogLevel;
22
+ private readonly file: string | undefined;
23
+ private readonly quiet: boolean;
24
+ private fileBroken = false;
25
+
26
+ constructor(opts: LoggerOptions = {}) {
27
+ this.level = opts.level ?? "info";
28
+ this.file = opts.file;
29
+ this.quiet = opts.quiet ?? false;
30
+ }
31
+
32
+ debug(msg: string, fields?: LogFields): void {
33
+ this.emit("debug", msg, fields);
34
+ }
35
+ info(msg: string, fields?: LogFields): void {
36
+ this.emit("info", msg, fields);
37
+ }
38
+ warn(msg: string, fields?: LogFields): void {
39
+ this.emit("warn", msg, fields);
40
+ }
41
+ error(msg: string, fields?: LogFields): void {
42
+ this.emit("error", msg, fields);
43
+ }
44
+
45
+ /**
46
+ * A durable record of something treekeeper *did* (or decided not to do).
47
+ * Always written to the log file regardless of level, because this is the
48
+ * audit trail a developer reads after a worktree disappears.
49
+ */
50
+ event(type: string, fields: LogFields = {}): void {
51
+ this.write({ ts: new Date().toISOString(), level: "info", type, ...redactValue(fields) });
52
+ }
53
+
54
+ private emit(level: LogLevel, rawMsg: string, rawFields?: LogFields): void {
55
+ const msg = redactSecrets(rawMsg);
56
+ const fields = rawFields ? redactValue(rawFields) : undefined;
57
+ this.write({ ts: new Date().toISOString(), level, msg, ...(fields ?? {}) });
58
+ if (this.quiet || ORDER[level] < ORDER[this.level]) return;
59
+ const tag =
60
+ level === "error" ? color.red("error") :
61
+ level === "warn" ? color.yellow("warn") :
62
+ level === "debug" ? color.gray("debug") :
63
+ color.gray(formatClock(Date.now()));
64
+ const extra = fields && Object.keys(fields).length > 0 ? color.gray(` ${compact(fields)}`) : "";
65
+ // stderr, so that `--json` keeps stdout machine-readable.
66
+ process.stderr.write(`${tag} ${msg}${extra}\n`);
67
+ }
68
+
69
+ private write(record: LogFields): void {
70
+ if (!this.file || this.fileBroken) return;
71
+ try {
72
+ mkdirSync(dirname(this.file), { recursive: true });
73
+ appendFileSync(this.file, JSON.stringify(record) + "\n");
74
+ } catch {
75
+ // A broken log file must never take the daemon down; report once.
76
+ this.fileBroken = true;
77
+ if (!this.quiet) {
78
+ process.stderr.write(color.yellow(`warn could not write log file ${this.file}\n`));
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ function compact(fields: LogFields): string {
85
+ return Object.entries(fields)
86
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
87
+ .join(" ");
88
+ }