@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.
- package/bin/empirical.js +125 -0
- package/lib/auth.js +804 -0
- package/lib/constants.js +491 -0
- package/lib/deviceConfig.js +94 -0
- package/lib/doc.js +50 -0
- package/lib/doctor.js +116 -0
- package/lib/env.js +49 -0
- package/lib/fsUtils.js +90 -0
- package/lib/help.js +113 -0
- package/lib/http.js +179 -0
- package/lib/installWizard.js +383 -0
- package/lib/installers/claude.js +89 -0
- package/lib/installers/codex.js +78 -0
- package/lib/installers/copilot.js +77 -0
- package/lib/installers/detect.js +47 -0
- package/lib/installers/hooks.js +128 -0
- package/lib/installers/nativePlugin.js +87 -0
- package/lib/installers/promptBlocks.js +403 -0
- package/lib/kitCommands.js +622 -0
- package/lib/memoryCommands.js +528 -0
- package/lib/oauthClient.js +222 -0
- package/lib/pluginCommands.js +81 -0
- package/lib/pluginUpdateCheck.js +143 -0
- package/lib/policyCommands.js +144 -0
- package/lib/scheduledSync.js +331 -0
- package/lib/skillSync.js +508 -0
- package/lib/ui.js +280 -0
- package/lib/workspaceCommands.js +323 -0
- package/lib/workspaceContext.js +76 -0
- package/package.json +25 -0
|
@@ -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, '&')
|
|
127
|
+
.replace(/</g, '<')
|
|
128
|
+
.replace(/>/g, '>');
|
|
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
|
+
};
|