@empirical-gauzza/cli 1.0.9

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.
@@ -0,0 +1,331 @@
1
+ 'use strict';
2
+
3
+ // US-012: OS-native scheduled-task management for the autosync delivery "floor" — no custom
4
+ // background daemon, just a per-OS scheduler entry that periodically shells out to
5
+ // `node <cli-entry> kit sync --quiet`. See kitCommands.js (runKitUse/runKitAutosync) for the
6
+ // reconciliation call sites, and deviceConfig.js's `scheduledSyncKitId` field for how we track
7
+ // which Kit "owns" the currently-registered local schedule.
8
+ //
9
+ // Every OS-command-execution call in this file funnels through `execCommand()` below, which is
10
+ // deliberately exported so callers/tests can invoke it directly, and which supports a
11
+ // fully-mocked "fake mode" (see EMPIRICAL_SCHEDULER_FAKE_LOG below) so CLI-level tests that
12
+ // spawn the real `empirical` binary as a subprocess can assert on exactly what this module
13
+ // *would* have run/written, on any host OS, without ever touching a real OS scheduler.
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+ const { commandExists, runResolvedCommand, ensureDir } = require('./fsUtils');
18
+
19
+ // ─── Config ─────────────────────────────────────────────────────────────────────────────────
20
+
21
+ const DEFAULT_INTERVAL_MIN = 20;
22
+ const MIN_INTERVAL_MIN = 5; // floor: this is a drift-catch-up mechanism, not a tight poll.
23
+
24
+ const WINDOWS_TASK_NAME = 'EmpiricalKitSync';
25
+ const LAUNCHD_LABEL = 'com.empirical.kitsync';
26
+ const CRON_START_MARKER = '# EMPIRICAL_KIT_SYNC_START';
27
+ const CRON_END_MARKER = '# EMPIRICAL_KIT_SYNC_END';
28
+
29
+ // Interval is configurable via EMPIRICAL_KIT_SYNC_INTERVAL_MIN (minutes), default 20, floored
30
+ // at 5 so a misconfigured env var can't turn this into a tight poll loop.
31
+ function getSyncIntervalMinutes() {
32
+ const raw = Number(process.env.EMPIRICAL_KIT_SYNC_INTERVAL_MIN);
33
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_INTERVAL_MIN;
34
+ return Math.max(MIN_INTERVAL_MIN, Math.round(raw));
35
+ }
36
+
37
+ // The scheduled task must invoke `node <absolute-path-to-empirical.js> kit sync --quiet`
38
+ // directly — never rely on `empirical` being on PATH, since a scheduled task's PATH may differ
39
+ // from an interactive shell's.
40
+ function getCliEntryPath() {
41
+ return path.resolve(__dirname, '../bin/empirical.js');
42
+ }
43
+
44
+ function getNodePath() {
45
+ return process.execPath;
46
+ }
47
+
48
+ // Platform is normally just process.platform, but tests running on one dev machine (this repo
49
+ // is developed on Windows) need to exercise the macOS/Linux branches too — so it's overridable
50
+ // via EMPIRICAL_SCHEDULER_PLATFORM ('win32' | 'darwin' | 'linux').
51
+ function getPlatform() {
52
+ return process.env.EMPIRICAL_SCHEDULER_PLATFORM || process.platform;
53
+ }
54
+
55
+ // ─── Command-execution seam (mockable) ─────────────────────────────────────────────────────
56
+ //
57
+ // When EMPIRICAL_SCHEDULER_FAKE_LOG is set (a file path), no real OS command is ever run: each
58
+ // call is appended as a JSON line to that file (command, args, input) and a canned success
59
+ // result is returned instead. `crontab -l`'s "current crontab" reply in fake mode comes from
60
+ // EMPIRICAL_SCHEDULER_FAKE_CRONTAB (empty/unset = "no crontab yet", matching real `crontab -l`
61
+ // exiting non-zero when the user has none).
62
+ function fakeExecCommand(command, args, options) {
63
+ const logPath = process.env.EMPIRICAL_SCHEDULER_FAKE_LOG;
64
+ ensureDir(path.dirname(logPath));
65
+ fs.appendFileSync(
66
+ logPath,
67
+ `${JSON.stringify({ command, args, input: options.input ?? null })}\n`,
68
+ 'utf8'
69
+ );
70
+
71
+ if (command === 'crontab' && args[0] === '-l') {
72
+ const existing = process.env.EMPIRICAL_SCHEDULER_FAKE_CRONTAB || '';
73
+ return existing
74
+ ? { status: 0, stdout: existing, stderr: '', error: null }
75
+ : { status: 1, stdout: '', stderr: 'no crontab for user', error: null };
76
+ }
77
+
78
+ return { status: 0, stdout: '', stderr: '', error: null };
79
+ }
80
+
81
+ function execCommand(command, args, options = {}) {
82
+ if (process.env.EMPIRICAL_SCHEDULER_FAKE_LOG) {
83
+ return fakeExecCommand(command, args, options);
84
+ }
85
+ return runResolvedCommand(command, args, { stdio: 'pipe', ...options });
86
+ }
87
+
88
+ // In fake/test mode the target OS's real command (schtasks/launchctl/crontab) very likely isn't
89
+ // even installed on the machine actually running the test suite (e.g. testing the Linux crontab
90
+ // branch on a Windows dev box) — so the "is this command available" check is skipped entirely
91
+ // when faking, same as every other OS-command execution in this module.
92
+ function commandAvailable(cmd) {
93
+ if (process.env.EMPIRICAL_SCHEDULER_FAKE_LOG) return true;
94
+ return commandExists(cmd);
95
+ }
96
+
97
+ // ─── Pure builders (no I/O — directly unit-testable) ───────────────────────────────────────
98
+
99
+ function buildSyncCommandString({ nodePath = getNodePath(), cliEntryPath = getCliEntryPath() } = {}) {
100
+ // A single shell-ready string, used for schtasks' /tr and shown in the launchd plist's
101
+ // ProgramArguments (split there instead — see buildLaunchdPlistXml).
102
+ return `"${nodePath}" "${cliEntryPath}" kit sync --quiet`;
103
+ }
104
+
105
+ function buildWindowsCreateArgs({ intervalMinutes, nodePath = getNodePath(), cliEntryPath = getCliEntryPath(), taskName = WINDOWS_TASK_NAME } = {}) {
106
+ return [
107
+ '/create',
108
+ '/tn', taskName,
109
+ '/tr', buildSyncCommandString({ nodePath, cliEntryPath }),
110
+ '/sc', 'minute',
111
+ '/mo', String(intervalMinutes),
112
+ '/f' // force-overwrite: makes re-registration idempotent instead of erroring "already exists"
113
+ ];
114
+ }
115
+
116
+ function buildWindowsDeleteArgs({ taskName = WINDOWS_TASK_NAME } = {}) {
117
+ return ['/delete', '/tn', taskName, '/f'];
118
+ }
119
+
120
+ function getLaunchAgentPlistPath() {
121
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
122
+ }
123
+
124
+ function xmlEscape(value) {
125
+ return String(value)
126
+ .replace(/&/g, '&amp;')
127
+ .replace(/</g, '&lt;')
128
+ .replace(/>/g, '&gt;');
129
+ }
130
+
131
+ function buildLaunchdPlistXml({
132
+ intervalMinutes,
133
+ nodePath = getNodePath(),
134
+ cliEntryPath = getCliEntryPath(),
135
+ label = LAUNCHD_LABEL
136
+ } = {}) {
137
+ const intervalSeconds = intervalMinutes * 60;
138
+ const args = [nodePath, cliEntryPath, 'kit', 'sync', '--quiet']
139
+ .map((a) => ` <string>${xmlEscape(a)}</string>`)
140
+ .join('\n');
141
+
142
+ return `<?xml version="1.0" encoding="UTF-8"?>
143
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
144
+ <plist version="1.0">
145
+ <dict>
146
+ <key>Label</key>
147
+ <string>${xmlEscape(label)}</string>
148
+ <key>ProgramArguments</key>
149
+ <array>
150
+ ${args}
151
+ </array>
152
+ <key>StartInterval</key>
153
+ <integer>${intervalSeconds}</integer>
154
+ <key>RunAtLoad</key>
155
+ <false/>
156
+ </dict>
157
+ </plist>
158
+ `;
159
+ }
160
+
161
+ function buildCronLine({ intervalMinutes, nodePath = getNodePath(), cliEntryPath = getCliEntryPath() } = {}) {
162
+ return `*/${intervalMinutes} * * * * "${nodePath}" "${cliEntryPath}" kit sync --quiet`;
163
+ }
164
+
165
+ // Idempotently inserts/replaces the marked EMPIRICAL_KIT_SYNC block inside an existing crontab
166
+ // body. Never touches any other line the user already has.
167
+ function mergeCrontab(existingContent, cronLine) {
168
+ const block = `${CRON_START_MARKER}\n${cronLine}\n${CRON_END_MARKER}`;
169
+ const lines = (existingContent || '').split(/\r?\n/);
170
+ const startIdx = lines.findIndex((l) => l.trim() === CRON_START_MARKER);
171
+ const endIdx = lines.findIndex((l) => l.trim() === CRON_END_MARKER);
172
+
173
+ let nextLines;
174
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
175
+ nextLines = [...lines.slice(0, startIdx), block, ...lines.slice(endIdx + 1)];
176
+ } else {
177
+ const trimmed = lines.filter((l, i) => !(i === lines.length - 1 && l === ''));
178
+ nextLines = trimmed.length ? [...trimmed, block] : [block];
179
+ }
180
+
181
+ return `${nextLines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
182
+ }
183
+
184
+ // Removes the marked block, leaving every other crontab line untouched. No-op (returns the
185
+ // input unchanged) if the block isn't present.
186
+ function removeCronBlock(existingContent) {
187
+ const lines = (existingContent || '').split(/\r?\n/);
188
+ const startIdx = lines.findIndex((l) => l.trim() === CRON_START_MARKER);
189
+ const endIdx = lines.findIndex((l) => l.trim() === CRON_END_MARKER);
190
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
191
+ return existingContent || '';
192
+ }
193
+ const nextLines = [...lines.slice(0, startIdx), ...lines.slice(endIdx + 1)];
194
+ const joined = nextLines.join('\n').replace(/\n{3,}/g, '\n\n');
195
+ return joined.endsWith('\n') || joined === '' ? joined : `${joined}\n`;
196
+ }
197
+
198
+ // ─── Per-platform register/deregister ──────────────────────────────────────────────────────
199
+
200
+ function registerWindows(intervalMinutes) {
201
+ if (!commandAvailable('schtasks')) {
202
+ throw new Error('schtasks is not available on this machine — cannot register scheduled sync');
203
+ }
204
+ const result = execCommand('schtasks', buildWindowsCreateArgs({ intervalMinutes }));
205
+ if (result.status !== 0) {
206
+ throw new Error(`schtasks /create failed: ${(result.stderr || result.stdout || '').trim() || 'unknown error'}`);
207
+ }
208
+ }
209
+
210
+ function deregisterWindows() {
211
+ if (!commandAvailable('schtasks')) return; // nothing to clean up if it was never available
212
+ // /delete on a task that doesn't exist exits non-zero — that's an expected, harmless outcome
213
+ // of "deregister" being idempotent, so we don't treat it as a failure.
214
+ execCommand('schtasks', buildWindowsDeleteArgs());
215
+ }
216
+
217
+ // macOS: `launchctl bootstrap`/`bootout` (the modern gui-domain API) rather than the older
218
+ // `load`/`unload`, which Apple has deprecated since 10.10 in favor of bootstrap/bootout's
219
+ // explicit domain targeting and clearer error semantics.
220
+ function launchdGuiDomain() {
221
+ const uid = typeof os.userInfo === 'function' ? os.userInfo().uid : process.getuid?.();
222
+ return `gui/${uid}`;
223
+ }
224
+
225
+ function registerMacOS(intervalMinutes) {
226
+ const plistPath = getLaunchAgentPlistPath();
227
+ const xml = buildLaunchdPlistXml({ intervalMinutes });
228
+
229
+ if (process.env.EMPIRICAL_SCHEDULER_FAKE_LOG) {
230
+ execCommand('__write_plist__', [plistPath], { input: xml });
231
+ } else {
232
+ ensureDir(path.dirname(plistPath));
233
+ fs.writeFileSync(plistPath, xml, 'utf8');
234
+ }
235
+
236
+ if (!commandAvailable('launchctl')) {
237
+ throw new Error('launchctl is not available on this machine — cannot register scheduled sync');
238
+ }
239
+ const domain = launchdGuiDomain();
240
+ // bootout first (ignore failure — it fails harmlessly if nothing was loaded yet) so
241
+ // re-registration is idempotent instead of erroring on an already-loaded label.
242
+ execCommand('launchctl', ['bootout', `${domain}/${LAUNCHD_LABEL}`]);
243
+ const result = execCommand('launchctl', ['bootstrap', domain, plistPath]);
244
+ if (result.status !== 0) {
245
+ throw new Error(`launchctl bootstrap failed: ${(result.stderr || result.stdout || '').trim() || 'unknown error'}`);
246
+ }
247
+ }
248
+
249
+ function deregisterMacOS() {
250
+ const plistPath = getLaunchAgentPlistPath();
251
+ if (commandAvailable('launchctl')) {
252
+ const domain = launchdGuiDomain();
253
+ execCommand('launchctl', ['bootout', `${domain}/${LAUNCHD_LABEL}`]);
254
+ }
255
+ if (process.env.EMPIRICAL_SCHEDULER_FAKE_LOG) {
256
+ execCommand('__remove_plist__', [plistPath]);
257
+ } else if (fs.existsSync(plistPath)) {
258
+ fs.rmSync(plistPath, { force: true });
259
+ }
260
+ }
261
+
262
+ function registerLinux(intervalMinutes) {
263
+ if (!commandAvailable('crontab')) {
264
+ throw new Error('crontab is not available on this machine — cannot register scheduled sync');
265
+ }
266
+ const readResult = execCommand('crontab', ['-l']);
267
+ const existing = readResult.status === 0 ? (readResult.stdout || '') : '';
268
+ const nextContent = mergeCrontab(existing, buildCronLine({ intervalMinutes }));
269
+ const writeResult = execCommand('crontab', ['-'], { input: nextContent });
270
+ if (writeResult.status !== 0) {
271
+ throw new Error(`crontab write failed: ${(writeResult.stderr || writeResult.stdout || '').trim() || 'unknown error'}`);
272
+ }
273
+ }
274
+
275
+ function deregisterLinux() {
276
+ if (!commandAvailable('crontab')) return;
277
+ const readResult = execCommand('crontab', ['-l']);
278
+ const existing = readResult.status === 0 ? (readResult.stdout || '') : '';
279
+ const nextContent = removeCronBlock(existing);
280
+ if (nextContent === existing) return; // block wasn't present — nothing to do
281
+ execCommand('crontab', ['-'], { input: nextContent });
282
+ }
283
+
284
+ // ─── Public entrypoints ─────────────────────────────────────────────────────────────────────
285
+
286
+ // Registers (or idempotently re-registers/updates) the per-OS scheduled task that runs
287
+ // `node <cli-entry> kit sync --quiet` on the configured interval. Throws on failure — callers
288
+ // decide whether that's fatal to the invoking command (see kitCommands.js, which treats
289
+ // scheduler failures as non-fatal/best-effort so a broken scheduler never blocks `kit use`).
290
+ function registerScheduledSync() {
291
+ const intervalMinutes = getSyncIntervalMinutes();
292
+ const platform = getPlatform();
293
+ if (platform === 'win32') return registerWindows(intervalMinutes);
294
+ if (platform === 'darwin') return registerMacOS(intervalMinutes);
295
+ if (platform === 'linux') return registerLinux(intervalMinutes);
296
+ throw new Error(`Scheduled sync is not supported on platform "${platform}"`);
297
+ }
298
+
299
+ // Deregisters the per-OS scheduled task. Always idempotent/best-effort: calling this when
300
+ // nothing is registered is a harmless no-op, never an error.
301
+ function deregisterScheduledSync() {
302
+ const platform = getPlatform();
303
+ if (platform === 'win32') return deregisterWindows();
304
+ if (platform === 'darwin') return deregisterMacOS();
305
+ if (platform === 'linux') return deregisterLinux();
306
+ throw new Error(`Scheduled sync is not supported on platform "${platform}"`);
307
+ }
308
+
309
+ module.exports = {
310
+ DEFAULT_INTERVAL_MIN,
311
+ MIN_INTERVAL_MIN,
312
+ WINDOWS_TASK_NAME,
313
+ LAUNCHD_LABEL,
314
+ CRON_START_MARKER,
315
+ CRON_END_MARKER,
316
+ getSyncIntervalMinutes,
317
+ getCliEntryPath,
318
+ getNodePath,
319
+ getPlatform,
320
+ getLaunchAgentPlistPath,
321
+ execCommand,
322
+ buildSyncCommandString,
323
+ buildWindowsCreateArgs,
324
+ buildWindowsDeleteArgs,
325
+ buildLaunchdPlistXml,
326
+ buildCronLine,
327
+ mergeCrontab,
328
+ removeCronBlock,
329
+ registerScheduledSync,
330
+ deregisterScheduledSync
331
+ };