-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.ts
More file actions
240 lines (225 loc) · 7.61 KB
/
Copy pathinstall.ts
File metadata and controls
240 lines (225 loc) · 7.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { atomicWrite } from "../fs-utils.js";
import { lisaGlobalHome } from "../paths.js";
import { escapeXml, resolveLisaArgv, resolveLisaBin, runCmd } from "../launchd.js";
export interface InstallOptions {
/** Cron-like spec: "*\/30 * * * *" or shorthand like "every:30m". Default: every 30 min. */
schedule?: string;
/** Path to the `lisa` binary; auto-detected if omitted. */
binPath?: string;
/** macOS only: load the plist immediately. */
load?: boolean;
}
const PLIST_LABEL = "ai.lisa.heartbeat";
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
const HEARTBEAT_LOG = path.join(lisaGlobalHome(), "heartbeat.log");
export async function installHeartbeat(
opts: InstallOptions = {},
): Promise<{ platform: string; instructions: string; written?: string }> {
const platform = process.platform;
const intervalSec = parseSchedule(opts.schedule ?? "every:30m");
const binPath = opts.binPath ?? (await resolveLisaBin());
if (platform === "darwin") {
const programArgv = await resolveLisaArgv(binPath);
const plist = renderPlist({
label: PLIST_LABEL,
argv: programArgv,
intervalSec,
logPath: HEARTBEAT_LOG,
});
await fs.mkdir(path.dirname(PLIST_PATH), { recursive: true });
await atomicWrite(PLIST_PATH, plist);
let loadResult = "";
if (opts.load) {
try {
await runCmd("launchctl", ["unload", PLIST_PATH]);
} catch {
// not loaded yet — unload before load is only a courtesy
}
try {
await runCmd("launchctl", ["load", "-w", PLIST_PATH]);
loadResult = `\nLoaded into launchd. To stop: launchctl unload ${PLIST_PATH}`;
} catch (err) {
loadResult = `\nWrote plist but failed to load: ${(err as Error).message}`;
}
}
return {
platform,
written: PLIST_PATH,
instructions: [
`Wrote launchd plist: ${PLIST_PATH}`,
` binary: ${binPath}`,
` interval: every ${intervalSec}s`,
` log: ${HEARTBEAT_LOG}`,
``,
opts.load
? loadResult.trim()
: `To start: launchctl load -w ${PLIST_PATH}\nTo stop: launchctl unload ${PLIST_PATH}`,
].join("\n"),
};
}
// Linux / WSL → cron snippet (do not write to user's crontab automatically)
const approx = secondsToCronApprox(intervalSec);
const cmd = `${binPath} heartbeat run >> ${HEARTBEAT_LOG} 2>&1`;
const lines = [
`Lisa doesn't auto-edit your crontab on this platform. Add this line manually:`,
``,
` ${approx.cron} ${cmd}`,
``,
];
if (approx.snapped) {
lines.push(
`Note: requested ${formatSec(intervalSec)} doesn't map cleanly to a single cron expression`,
`(cron can't span midnight evenly with hour-divisors other than 1,2,3,4,6,8,12).`,
`Snapped to the nearest safe divisor: ${formatSec(approx.effectiveSec)}.`,
``,
);
}
lines.push(`Run \`crontab -e\` and append it. Verify with \`crontab -l\`.`);
return {
platform,
instructions: lines.join("\n"),
};
}
function renderPlist(opts: {
label: string;
argv: string[];
intervalSec: number;
logPath: string;
}): string {
const argvXml = [...opts.argv, "heartbeat", "run"]
.map((a) => ` <string>${escapeXml(a)}</string>`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${opts.label}</string>
<key>ProgramArguments</key>
<array>
${argvXml}
</array>
<key>StartInterval</key>
<integer>${opts.intervalSec}</integer>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>${opts.logPath}</string>
<key>StandardErrorPath</key>
<string>${opts.logPath}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
</dict>
</plist>
`;
}
export async function uninstallHeartbeat(): Promise<string> {
if (process.platform !== "darwin") {
return "Auto-uninstall only supported on macOS. Edit your crontab manually.";
}
try {
await runCmd("launchctl", ["unload", PLIST_PATH]);
} catch {
// already unloaded — proceed to remove the plist
}
try {
await fs.unlink(PLIST_PATH);
return `Removed ${PLIST_PATH}`;
} catch (err) {
return `Could not remove ${PLIST_PATH}: ${(err as Error).message}`;
}
}
function parseSchedule(spec: string): number {
// "every:30m" / "every:1h" / "every:5m"
const m = spec.match(/^every:(\d+)([smh])$/);
if (m) {
const n = parseInt(m[1]!, 10);
const unit = m[2]!;
return n * (unit === "s" ? 1 : unit === "m" ? 60 : 3600);
}
const sec = parseInt(spec, 10);
if (Number.isFinite(sec) && sec > 0) return sec;
return 1800; // default 30 min
}
/** Cron field divisors that span the field cleanly (no wrap-around gap). */
const SAFE_MIN_DIVISORS = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30];
const SAFE_HOUR_DIVISORS = [1, 2, 3, 4, 6, 8, 12];
function pickClosest(target: number, choices: readonly number[]): number {
return choices.reduce((best, v) => (Math.abs(v - target) < Math.abs(best - target) ? v : best));
}
/**
* Convert an interval in seconds to a cron expression.
*
* cron's `*\/N` syntax only spans a field evenly when N divides the field's
* range (60 for minutes, 24 for hours). For other values cron wraps at the
* field boundary, producing a short final gap (e.g. `*\/5` on hours fires at
* 0,5,10,15,20 then 0 — only 4h between 20 and 0). For minute values >59 or
* hour values >23 cron silently ignores the field. To keep the printed line
* valid and the schedule even, we snap to the nearest safe divisor and let the
* caller report the rounding to the user.
*/
function secondsToCronApprox(sec: number): {
cron: string;
snapped: boolean;
effectiveSec: number;
} {
// <1 minute → fall back to every-minute (cron's smallest granularity)
if (sec < 60) {
return { cron: "* * * * *", snapped: sec !== 60, effectiveSec: 60 };
}
// 1m–<1h → minute granularity, snap to divisor of 60
if (sec < 3600) {
const requestedMin = sec / 60;
const min = pickClosest(requestedMin, SAFE_MIN_DIVISORS);
return {
cron: min === 1 ? "* * * * *" : `*/${min} * * * *`,
snapped: min !== requestedMin,
effectiveSec: min * 60,
};
}
// 1h–24h → hour granularity, snap to divisor of 24
if (sec <= 24 * 3600) {
const requestedH = sec / 3600;
const hours = pickClosest(requestedH, SAFE_HOUR_DIVISORS);
if (sec === 24 * 3600) {
return { cron: "0 0 * * *", snapped: false, effectiveSec: 24 * 3600 };
}
return {
cron: hours === 1 ? "0 * * * *" : `0 */${hours} * * *`,
snapped: hours !== requestedH,
effectiveSec: hours * 3600,
};
}
// >24h → day granularity
const requestedD = sec / 86400;
if (requestedD >= 7) {
return { cron: "0 0 * * 0", snapped: true, effectiveSec: 7 * 86400 };
}
const days = Math.max(1, Math.round(requestedD));
return {
cron: days === 1 ? "0 0 * * *" : `0 0 */${days} * *`,
snapped: days !== requestedD,
effectiveSec: days * 86400,
};
}
function formatSec(sec: number): string {
if (sec % 86400 === 0) {
const d = sec / 86400;
return d === 1 ? "1 day" : `${d} days`;
}
if (sec % 3600 === 0) {
const h = sec / 3600;
return h === 1 ? "1 hour" : `${h} hours`;
}
if (sec % 60 === 0) {
const m = sec / 60;
return m === 1 ? "1 minute" : `${m} minutes`;
}
return `${sec} seconds`;
}