@usagefleet/cli 1.2.59

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,524 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { installPromptHook, uninstallPromptHook } from './hook.js';
6
+ import { readStore } from './store.js';
7
+ import { row, step } from './ui.js';
8
+ const LABEL = 'dev.usagefleet.collector';
9
+ /** Scheduled Task name on Windows (mirrors the launchd label / systemd unit). */
10
+ const TASK = 'usagefleet';
11
+ /** Extra env var the service needs that does not carry the USAGEFLEET_ prefix. */
12
+ const EXTRA_PASSTHROUGH_ENV = 'ANTHROPIC_API_KEY';
13
+ /** Per-user dir for the collector's own runtime files: the Windows launcher and
14
+ * its log, plus the binary copy that pre-npm releases left there. */
15
+ function stableBinDir() {
16
+ if (process.platform === 'darwin') {
17
+ return join(homedir(), 'Library', 'Application Support', 'usagefleet');
18
+ }
19
+ if (process.platform === 'win32') {
20
+ return join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'usagefleet');
21
+ }
22
+ return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'usagefleet');
23
+ }
24
+ /** Where the Windows launcher sends the collector's stdout/stderr (systemd has
25
+ * the journal — a hidden task has nowhere else to go). */
26
+ function windowsLogPath() {
27
+ return join(stableBinDir(), 'usagefleet.log');
28
+ }
29
+ /** launchd log dir. Not /tmp: that is world-writable, so any other local account
30
+ * could pre-create the log path as a symlink and have the agent write through
31
+ * it as this user. ~/Library/Logs is the platform's answer and is user-owned. */
32
+ function macLogDir() {
33
+ return join(homedir(), 'Library', 'Logs', 'usagefleet');
34
+ }
35
+ function windowsVbsPath() {
36
+ return join(stableBinDir(), 'usagefleet-watch.vbs');
37
+ }
38
+ /** Where releases before the npm switch parked their copy of the binary. Only
39
+ * cleanup touches it now. */
40
+ function stableBinPath() {
41
+ return join(stableBinDir(), process.platform === 'win32' ? 'usagefleet.exe' : 'usagefleet');
42
+ }
43
+ /** True when running as a compiled single-file executable rather than
44
+ * `node dist/index.js`. A bun `--compile` binary has no real re-invokable
45
+ * script at argv[1]: it's absent, equals the exec path, or points into bun's
46
+ * virtual bundle filesystem (`/$bunfs/…` on POSIX, `…~BUN\…` on Windows).
47
+ * Treating that virtual path as a real script (the old bug) baked a bogus
48
+ * argument into the service command, so the launched process saw an unknown
49
+ * command, printed help, and exited cleanly — leaving the service down. */
50
+ export function looksLikeCompiledBinary(scriptPath, execPath) {
51
+ if (!scriptPath || scriptPath === execPath) {
52
+ return true;
53
+ }
54
+ if (scriptPath.includes('/$bunfs/')) {
55
+ return true;
56
+ }
57
+ if (/[\\/]~BUN[\\/]/.test(scriptPath)) {
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ /** The `usagefleet` a shell would run, when that is NOT this install — e.g. the
63
+ * standalone binary a pre-npm release left in /usr/local/bin, which sits ahead
64
+ * of the npm prefix on most PATHs and would keep answering after an upgrade.
65
+ * Only the first hit matters: that is the one the shell picks. */
66
+ export function shadowingBinary(pathEnv, self) {
67
+ const name = process.platform === 'win32' ? 'usagefleet.exe' : 'usagefleet';
68
+ const real = (p) => {
69
+ try {
70
+ return realpathSync(p);
71
+ }
72
+ catch {
73
+ return p;
74
+ }
75
+ };
76
+ for (const dir of (pathEnv || '').split(delimiter).filter(Boolean)) {
77
+ const candidate = join(dir, name);
78
+ if (existsSync(candidate)) {
79
+ return real(candidate) === real(self) ? null : candidate;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+ /** Program + leading args to launch `watch`. npm installs a script, so this is
85
+ * normally an absolute node plus the global package path — both survive PATH
86
+ * being nearly empty, which is what launchd and systemd hand the service. */
87
+ function programArgs() {
88
+ const script = process.argv[1];
89
+ if (looksLikeCompiledBinary(script, process.execPath)) {
90
+ // Only a locally built `bun --compile` binary reaches this now: it has no
91
+ // re-invokable script, so the service launches the executable itself.
92
+ return [process.execPath, 'watch'];
93
+ }
94
+ return [process.execPath, script, 'watch'];
95
+ }
96
+ function macPlistPath() {
97
+ return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
98
+ }
99
+ function systemdUnitPath() {
100
+ return join(homedir(), '.config', 'systemd', 'user', 'usagefleet.service');
101
+ }
102
+ /** Env vars that are actually set, for baking into the unit so the service
103
+ * behaves like the install shell. Derived from the USAGEFLEET_ prefix rather
104
+ * than a hand-kept allowlist: that list had already drifted, silently dropping
105
+ * USAGEFLEET_PI, USAGEFLEET_DESKTOP and USAGEFLEET_LIMITS_INTERVAL, so a
106
+ * documented override did nothing once the collector ran as a service. */
107
+ function presentEnv() {
108
+ return Object.entries(process.env).filter((entry) => !!entry[1] && (entry[0].startsWith('USAGEFLEET_') || entry[0] === EXTRA_PASSTHROUGH_ENV));
109
+ }
110
+ /** Escape a string for a VBScript double-quoted literal (only `"` is special). */
111
+ function vbs(s) {
112
+ return `"${s.replaceAll('"', '""')}"`;
113
+ }
114
+ /** Hidden launcher for the Scheduled Task. A bun-compiled collector is a console
115
+ * app, so running it straight from Task Scheduler pops a console window that
116
+ * stays up for the whole session; wscript.exe is windowless and starts the
117
+ * collector with window style 0. It also carries the USAGEFLEET_* env the way
118
+ * the plist/unit does (Task XML has no env support) and redirects output to a
119
+ * log file, since a hidden process has no console to print to.
120
+ * Waits (`True`) so the task instance lives as long as the collector — that's
121
+ * what makes RestartOnFailure in the task XML meaningful. */
122
+ export function windowsLauncherVbs(prog, env, logPath) {
123
+ const quoted = prog.map(p => `"${p}"`).join(' ');
124
+ // `cmd /c ""prog" args > "log""` is cmd's canonical form for quoted paths.
125
+ const cmdLine = `cmd /c "${quoted} > "${logPath}" 2>&1"`;
126
+ const envLines = env
127
+ // A newline would end the VBS statement; such a value can't be represented.
128
+ .filter(([, v]) => !/[\r\n]/.test(v))
129
+ .map(([k, v]) => `env(${vbs(k)}) = ${vbs(v)}`);
130
+ return [
131
+ "' usagefleet background launcher — generated by `usagefleet install`.",
132
+ 'Set sh = CreateObject("WScript.Shell")',
133
+ 'Set env = sh.Environment("Process")',
134
+ ...envLines,
135
+ `sh.Run ${vbs(cmdLine)}, 0, True`,
136
+ '',
137
+ ].join('\r\n');
138
+ }
139
+ /** Scheduled Task definition: run at logon, restart on failure, no time limit —
140
+ * the Windows equivalent of RunAtLoad+KeepAlive / Restart=always.
141
+ * <Settings> children follow the order Windows itself exports; the schema is a
142
+ * strict sequence and rejects the whole file if they're shuffled. */
143
+ export function windowsTaskXml(vbsPath, userId) {
144
+ return `<?xml version="1.0" encoding="UTF-16"?>
145
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
146
+ <RegistrationInfo>
147
+ <Description>UsageFleet collector</Description>
148
+ </RegistrationInfo>
149
+ <Triggers>
150
+ <LogonTrigger>
151
+ <Enabled>true</Enabled>
152
+ <UserId>${xml(userId)}</UserId>
153
+ </LogonTrigger>
154
+ </Triggers>
155
+ <Principals>
156
+ <Principal id="Author">
157
+ <UserId>${xml(userId)}</UserId>
158
+ <LogonType>InteractiveToken</LogonType>
159
+ <RunLevel>LeastPrivilege</RunLevel>
160
+ </Principal>
161
+ </Principals>
162
+ <Settings>
163
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
164
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
165
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
166
+ <AllowHardTerminate>true</AllowHardTerminate>
167
+ <StartWhenAvailable>true</StartWhenAvailable>
168
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
169
+ <IdleSettings>
170
+ <StopOnIdleEnd>false</StopOnIdleEnd>
171
+ <RestartOnIdle>false</RestartOnIdle>
172
+ </IdleSettings>
173
+ <AllowStartOnDemand>true</AllowStartOnDemand>
174
+ <Enabled>true</Enabled>
175
+ <Hidden>false</Hidden>
176
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
177
+ <WakeToRun>false</WakeToRun>
178
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
179
+ <Priority>7</Priority>
180
+ <RestartOnFailure>
181
+ <Interval>PT1M</Interval>
182
+ <Count>3</Count>
183
+ </RestartOnFailure>
184
+ </Settings>
185
+ <Actions Context="Author">
186
+ <Exec>
187
+ <Command>wscript.exe</Command>
188
+ <Arguments>//B //Nologo "${xml(vbsPath)}"</Arguments>
189
+ </Exec>
190
+ </Actions>
191
+ </Task>
192
+ `;
193
+ }
194
+ /** Current user as DOMAIN\user (or just user), for the task's principal. */
195
+ function windowsUserId() {
196
+ const user = process.env.USERNAME || process.env.USER || '';
197
+ const domain = process.env.USERDOMAIN;
198
+ return domain ? `${domain}\\${user}` : user;
199
+ }
200
+ function schtasks(...args) {
201
+ try {
202
+ execFileSync('schtasks', args, { stdio: 'ignore' });
203
+ return true;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ function xml(s) {
210
+ return s
211
+ .replaceAll('&', '&amp;')
212
+ .replaceAll('<', '&lt;')
213
+ .replaceAll('>', '&gt;')
214
+ .replaceAll('"', '&quot;')
215
+ .replaceAll("'", '&apos;');
216
+ }
217
+ export function install() {
218
+ // Pre-flight: refuse to install a service that can't resolve an endpoint+token,
219
+ // otherwise the baked `watch` process throws on every launch and the service
220
+ // manager crash-loops it invisibly (only the log file shows it). Use the same
221
+ // env-OR-file precedence loadConfig() uses so a prior `init` is honored.
222
+ const file = readStore();
223
+ const endpoint = process.env.USAGEFLEET_ENDPOINT || file.endpoint || '';
224
+ const token = process.env.USAGEFLEET_TOKEN || file.token || '';
225
+ if (!endpoint || !token) {
226
+ console.error('No endpoint/token resolved. Run `usagefleet init --endpoint <url> --token <t>` ' +
227
+ '(or set USAGEFLEET_ENDPOINT and USAGEFLEET_TOKEN) before installing.');
228
+ process.exit(1);
229
+ }
230
+ // Windows: stop a running task first, or `schtasks /run` below is ignored (the
231
+ // task is IgnoreNew) — leaving the OLD version resident after an "update".
232
+ if (process.platform === 'win32') {
233
+ schtasks('/end', '/tn', TASK);
234
+ }
235
+ // Upgrading from a pre-npm release leaves its binary copy behind, and nothing
236
+ // points at it once the definition below is rewritten.
237
+ removeStableBin();
238
+ const prog = programArgs();
239
+ const shadow = shadowingBinary(process.env.PATH, process.argv[1] ?? process.execPath);
240
+ if (shadow) {
241
+ console.warn(`Another usagefleet is earlier on your PATH (${shadow}). The service below runs this one, ` +
242
+ `but your shell keeps running that one — delete it: rm ${shadow}`);
243
+ }
244
+ const env = presentEnv();
245
+ // Same binary, different entry point: the service watches, the hook enforces.
246
+ // `prog` ends in "watch"; everything before it is how to launch this build.
247
+ installPromptHook([...prog.slice(0, -1), 'guard']);
248
+ if (process.platform === 'darwin') {
249
+ const envXml = env.map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`).join('\n');
250
+ const progXml = prog.map(p => ` <string>${xml(p)}</string>`).join('\n');
251
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
252
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
253
+ <plist version="1.0">
254
+ <dict>
255
+ <key>Label</key><string>${xml(LABEL)}</string>
256
+ <key>ProgramArguments</key>
257
+ <array>
258
+ ${progXml}
259
+ </array>
260
+ <key>EnvironmentVariables</key>
261
+ <dict>
262
+ ${envXml}
263
+ </dict>
264
+ <key>RunAtLoad</key><true/>
265
+ <key>KeepAlive</key>
266
+ <dict>
267
+ <key>SuccessfulExit</key><false/>
268
+ </dict>
269
+ <key>ThrottleInterval</key><integer>30</integer>
270
+ <key>StandardErrorPath</key><string>${xml(join(macLogDir(), 'usagefleet.err.log'))}</string>
271
+ <key>StandardOutPath</key><string>${xml(join(macLogDir(), 'usagefleet.out.log'))}</string>
272
+ </dict>
273
+ </plist>
274
+ `;
275
+ const path = macPlistPath();
276
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
277
+ mkdirSync(macLogDir(), { recursive: true });
278
+ // 0600: this file carries USAGEFLEET_TOKEN and ANTHROPIC_API_KEY, the same
279
+ // secrets `init` deliberately writes at 0600.
280
+ writeFileSync(path, plist, { encoding: 'utf-8', mode: 0o600 });
281
+ chmodSync(path, 0o600); // writeFileSync's mode does not apply to an existing file
282
+ const domain = `gui/${process.getuid?.()}`;
283
+ // execFile (no shell) so `path` is never subject to shell interpolation.
284
+ // Reload-safe: boot out any previous instance first so re-running install
285
+ // (e.g. to apply an update) swaps in the new binary instead of leaving the
286
+ // old one resident.
287
+ try {
288
+ execFileSync('launchctl', ['bootout', domain, path], { stdio: 'ignore' });
289
+ }
290
+ catch {
291
+ /* not loaded yet — fine */
292
+ }
293
+ try {
294
+ execFileSync('launchctl', ['bootstrap', domain, path], {
295
+ stdio: 'inherit',
296
+ });
297
+ }
298
+ catch {
299
+ try {
300
+ execFileSync('launchctl', ['load', path], { stdio: 'inherit' });
301
+ }
302
+ catch {
303
+ /* report below; user can load manually */
304
+ }
305
+ }
306
+ // Force a (re)start so an update takes effect immediately, not on next respawn.
307
+ try {
308
+ execFileSync('launchctl', ['kickstart', '-k', `${domain}/${LABEL}`], {
309
+ stdio: 'ignore',
310
+ });
311
+ }
312
+ catch {
313
+ /* best-effort */
314
+ }
315
+ console.log(step('service', 'launchd · starts at login'));
316
+ return;
317
+ }
318
+ if (process.platform === 'linux') {
319
+ // systemd: quote values, escape backslash/quote, reject newlines.
320
+ const envLines = env
321
+ .filter(([, v]) => !/[\r\n]/.test(v))
322
+ .map(([k, v]) => `Environment="${k}=${v.replaceAll(/[\\"]/g, m => `\\${m}`)}"`)
323
+ .join('\n');
324
+ const unit = `[Unit]
325
+ Description=UsageFleet collector
326
+ Wants=network-online.target
327
+ After=network-online.target
328
+ # Cap respawns so a misconfigured unit can't loop forever.
329
+ StartLimitIntervalSec=120
330
+ StartLimitBurst=5
331
+
332
+ [Service]
333
+ ExecStart=${prog.join(' ')}
334
+ Restart=always
335
+ RestartSec=30
336
+ ${envLines}
337
+
338
+ [Install]
339
+ WantedBy=default.target
340
+ `;
341
+ const path = systemdUnitPath();
342
+ mkdirSync(join(homedir(), '.config', 'systemd', 'user'), {
343
+ recursive: true,
344
+ });
345
+ // 0600: the unit bakes USAGEFLEET_TOKEN and ANTHROPIC_API_KEY into Environment=.
346
+ writeFileSync(path, unit, { encoding: 'utf-8', mode: 0o600 });
347
+ chmodSync(path, 0o600); // writeFileSync's mode does not apply to an existing file
348
+ // Enable + start automatically so autostart "just works". `restart` after
349
+ // enable picks up a new binary when re-running install to apply an update
350
+ // (enable --now leaves an already-running unit untouched).
351
+ const sc = (...args) => {
352
+ try {
353
+ execFileSync('systemctl', ['--user', ...args], { stdio: 'inherit' });
354
+ return true;
355
+ }
356
+ catch {
357
+ return false;
358
+ }
359
+ };
360
+ const reloaded = sc('daemon-reload');
361
+ // Clear any prior failure / start-limit lockout so a re-install (e.g. to
362
+ // recover a unit that crash-looped on an older buggy binary) isn't rejected
363
+ // with "start request repeated too quickly". No-op on a healthy unit.
364
+ sc('reset-failed', 'usagefleet');
365
+ const enabled = sc('enable', '--now', 'usagefleet');
366
+ if (reloaded && enabled) {
367
+ sc('restart', 'usagefleet');
368
+ // Keep the user manager (and thus the service) alive after logout.
369
+ const user = process.env.USER || process.env.LOGNAME;
370
+ if (user) {
371
+ try {
372
+ execFileSync('loginctl', ['enable-linger', user], {
373
+ stdio: 'ignore',
374
+ });
375
+ }
376
+ catch {
377
+ /* not critical; service still runs while logged in */
378
+ }
379
+ }
380
+ console.log(step('service', 'systemd · starts at login'));
381
+ }
382
+ else {
383
+ console.log('Could not drive systemctl automatically. Enable it manually:');
384
+ console.log(' systemctl --user daemon-reload');
385
+ console.log(' systemctl --user enable --now usagefleet');
386
+ console.log(' loginctl enable-linger $USER # keep running after logout');
387
+ }
388
+ return;
389
+ }
390
+ if (process.platform === 'win32') {
391
+ const vbsPath = windowsVbsPath();
392
+ mkdirSync(stableBinDir(), { recursive: true });
393
+ // 0600: the launcher script embeds the same secrets as the plist/unit.
394
+ writeFileSync(vbsPath, windowsLauncherVbs(prog, env, windowsLogPath()), {
395
+ encoding: 'utf-8',
396
+ mode: 0o600,
397
+ });
398
+ // schtasks reads task XML as UTF-16 (a UTF-8 file is rejected as malformed).
399
+ const xmlPath = join(tmpdir(), `usagefleet-task-${process.pid}.xml`);
400
+ writeFileSync(xmlPath, `\uFEFF${windowsTaskXml(vbsPath, windowsUserId())}`, 'utf16le');
401
+ // /f replaces any previous definition, so install doubles as the updater.
402
+ const created = schtasks('/create', '/tn', TASK, '/xml', xmlPath, '/f') ||
403
+ // Fallback for hosts that reject the XML (locale/schema quirks): a plain
404
+ // onlogon task. Same launcher, minus restart-on-failure.
405
+ schtasks('/create', '/tn', TASK, '/sc', 'onlogon', '/f', '/tr', `wscript.exe //B //Nologo "${vbsPath}"`);
406
+ rmSync(xmlPath, { force: true });
407
+ if (!created) {
408
+ console.error('Could not register the scheduled task. Register it manually:\n' +
409
+ ` schtasks /create /tn ${TASK} /sc onlogon /tr "wscript.exe //B //Nologo \\"${vbsPath}\\""`);
410
+ process.exit(1);
411
+ }
412
+ // Start now so install/update takes effect immediately, not at next logon.
413
+ schtasks('/run', '/tn', TASK);
414
+ console.log(step('service', 'scheduled task · starts at logon'));
415
+ console.log(row('logs', windowsLogPath()));
416
+ return;
417
+ }
418
+ console.log(`Unsupported platform for service install: ${process.platform}.`);
419
+ console.log(`Run it yourself with: ${prog.join(' ')}`);
420
+ }
421
+ /** Is the background service actually up? This is the one question `status`
422
+ * has to answer, so every probe is best-effort: an unreadable or unparseable
423
+ * service manager reads as stopped rather than throwing. */
424
+ export function serviceStatus() {
425
+ // Capture stdout, silence stderr: a missing service is an expected answer here,
426
+ // not something to spill onto the user's terminal.
427
+ const query = (cmd, args) => {
428
+ try {
429
+ return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
430
+ }
431
+ catch {
432
+ return null;
433
+ }
434
+ };
435
+ if (process.platform === 'darwin') {
436
+ if (!existsSync(macPlistPath())) {
437
+ return { state: 'not installed' };
438
+ }
439
+ const out = query('launchctl', ['print', `gui/${process.getuid?.()}/${LABEL}`]);
440
+ const pid = out?.match(/\bpid = (\d+)/)?.[1];
441
+ return pid ? { pid: Number(pid), state: 'running' } : { state: 'stopped' };
442
+ }
443
+ if (process.platform === 'linux') {
444
+ if (!existsSync(systemdUnitPath())) {
445
+ return { state: 'not installed' };
446
+ }
447
+ const out = query('systemctl', ['--user', 'show', 'usagefleet', '--property=ActiveState,MainPID']);
448
+ if (!out?.includes('ActiveState=active')) {
449
+ return { state: 'stopped' };
450
+ }
451
+ const pid = Number(out.match(/MainPID=(\d+)/)?.[1] ?? 0);
452
+ return pid > 0 ? { pid, state: 'running' } : { state: 'running' };
453
+ }
454
+ if (process.platform === 'win32') {
455
+ const out = query('schtasks', ['/query', '/tn', TASK, '/fo', 'list']);
456
+ if (!out) {
457
+ return { state: 'not installed' };
458
+ }
459
+ return { state: /Status:\s*Running/i.test(out) ? 'running' : 'stopped' };
460
+ }
461
+ return { state: 'not installed' };
462
+ }
463
+ /** Best-effort removal of the ~60 MB binary copy that pre-npm releases parked in
464
+ * the app-support dir (plus the `.old` file a Windows in-place update left).
465
+ * Runs on install too, so upgrading off the old channel reclaims the space. */
466
+ function removeStableBin() {
467
+ for (const p of [stableBinPath(), `${stableBinPath()}.old`]) {
468
+ try {
469
+ rmSync(p, { force: true });
470
+ }
471
+ catch {
472
+ /* ignore */
473
+ }
474
+ }
475
+ }
476
+ export function uninstall() {
477
+ uninstallPromptHook();
478
+ if (process.platform === 'darwin') {
479
+ const path = macPlistPath();
480
+ try {
481
+ execFileSync('launchctl', ['bootout', `gui/${process.getuid?.()}`, path], {
482
+ stdio: 'inherit',
483
+ });
484
+ }
485
+ catch {
486
+ try {
487
+ execFileSync('launchctl', ['unload', path], { stdio: 'inherit' });
488
+ }
489
+ catch {
490
+ /* ignore */
491
+ }
492
+ }
493
+ removeStableBin();
494
+ console.log(`Removed launchd agent (delete ${path} to fully clean up).`);
495
+ return;
496
+ }
497
+ if (process.platform === 'linux') {
498
+ try {
499
+ execFileSync('systemctl', ['--user', 'disable', '--now', 'usagefleet'], {
500
+ stdio: 'inherit',
501
+ });
502
+ }
503
+ catch {
504
+ /* ignore */
505
+ }
506
+ removeStableBin();
507
+ console.log(`Disabled systemd unit (delete ${systemdUnitPath()} to fully clean up).`);
508
+ return;
509
+ }
510
+ if (process.platform === 'win32') {
511
+ schtasks('/end', '/tn', TASK);
512
+ const deleted = schtasks('/delete', '/tn', TASK, '/f');
513
+ try {
514
+ rmSync(windowsVbsPath(), { force: true });
515
+ }
516
+ catch {
517
+ /* ignore */
518
+ }
519
+ removeStableBin();
520
+ console.log(deleted ? `Removed scheduled task "${TASK}".` : `No scheduled task "${TASK}" found.`);
521
+ return;
522
+ }
523
+ console.log(`Nothing to uninstall on ${process.platform}.`);
524
+ }
package/dist/store.js ADDED
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { writeFileAtomic } from './atomic-write.js';
6
+ /**
7
+ * The collector's one file: settings, tail offsets and notification marks all
8
+ * live in `~/.config/usagefleet/config.json` (XDG_CONFIG_HOME honoured, and
9
+ * USAGEFLEET_CONFIG overrides the whole path). One file means one thing to
10
+ * back up, inspect, delete or bake into a service unit.
11
+ */
12
+ export function storePath() {
13
+ const override = process.env.USAGEFLEET_CONFIG;
14
+ if (override) {
15
+ return override;
16
+ }
17
+ const xdg = process.env.XDG_CONFIG_HOME;
18
+ return join(xdg && xdg.length > 0 ? xdg : join(homedir(), '.config'), 'usagefleet', 'config.json');
19
+ }
20
+ /** Where the three pre-consolidation files lived, honouring the env overrides
21
+ * that used to point at them so a customised install still migrates. */
22
+ function legacyPaths() {
23
+ return {
24
+ notify: process.env.USAGEFLEET_NOTIFY_STATE ?? join(homedir(), '.usagefleet-notify.json'),
25
+ settings: join(homedir(), '.usagefleet.json'),
26
+ state: process.env.USAGEFLEET_STATE ?? join(homedir(), '.usagefleet-state.json'),
27
+ };
28
+ }
29
+ function readJson(path) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
32
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
33
+ return null;
34
+ }
35
+ return parsed;
36
+ }
37
+ catch {
38
+ return null; // missing or corrupt → caller falls back to defaults
39
+ }
40
+ }
41
+ export function freshWindow() {
42
+ return { lastBucket: 0, resetsAt: null };
43
+ }
44
+ /** Fill in every field so callers get a total value, whatever the file held. */
45
+ function normalize(raw) {
46
+ return {
47
+ desktopDir: raw.desktopDir,
48
+ endpoint: raw.endpoint,
49
+ limits: raw.limits,
50
+ notify: {
51
+ fiveHour: { ...freshWindow(), ...raw.notify?.fiveHour },
52
+ sevenDay: { ...freshWindow(), ...raw.notify?.sevenDay },
53
+ },
54
+ piDir: raw.piDir,
55
+ projectsDir: raw.projectsDir,
56
+ state: {
57
+ deviceId: raw.state?.deviceId || randomUUID(),
58
+ files: raw.state?.files ?? {},
59
+ updatedAt: raw.state?.updatedAt ?? new Date().toISOString(),
60
+ },
61
+ token: raw.token,
62
+ version: 1,
63
+ };
64
+ }
65
+ /**
66
+ * Read the store, folding in the three legacy files when the consolidated one
67
+ * does not exist yet. Nothing is written here — the first `updateStore` commits
68
+ * the merged result — so a read-only command never rewrites the user's disk.
69
+ * The old files are left in place; an upgraded collector simply stops reading
70
+ * them, and a rollback still finds them intact.
71
+ */
72
+ export function readStore(path = storePath()) {
73
+ const direct = readJson(path);
74
+ if (direct) {
75
+ return normalize(direct);
76
+ }
77
+ const legacy = legacyPaths();
78
+ const settings = readJson(legacy.settings) ?? {};
79
+ const state = readJson(legacy.state);
80
+ const notify = readJson(legacy.notify);
81
+ return normalize({
82
+ ...settings,
83
+ notify: notify ?? undefined,
84
+ state: state ?? undefined,
85
+ });
86
+ }
87
+ /**
88
+ * Read-modify-write the store atomically. Re-reading inside the call is what
89
+ * lets `usagefleet init` change the token while the service is mid-cycle: the
90
+ * service's next save picks up the new token instead of overwriting it with the
91
+ * copy it loaded minutes ago.
92
+ *
93
+ * ponytail: read-then-write is not a lock, so two writers landing in the same
94
+ * few milliseconds can still lose one update. Take a lockfile if the collector
95
+ * ever grows a second concurrent writer; today it is one service plus the
96
+ * occasional human command.
97
+ */
98
+ export function updateStore(path, mutate) {
99
+ const store = readStore(path);
100
+ mutate(store);
101
+ mkdirSync(dirname(path), { recursive: true });
102
+ // 0600: the file holds the device token.
103
+ writeFileAtomic(path, `${JSON.stringify(store, null, 2)}\n`, 0o600);
104
+ }