@usagefleet/cli 1.2.152 → 1.2.153
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/README.md +2 -1
- package/dist/UsageFleet.app/Contents/Info.plist +26 -0
- package/dist/UsageFleet.app/Contents/MacOS/usagefleet-notifier +0 -0
- package/dist/UsageFleet.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/UsageFleet.app/Contents/_CodeSignature/CodeResources +128 -0
- package/dist/collector.js +19 -6
- package/dist/index.js +2 -1
- package/dist/notifier.js +14 -5
- package/dist/notify.js +38 -2
- package/dist/paths.js +20 -1
- package/dist/release.js +1 -1
- package/dist/scanner.js +42 -8
- package/dist/service.js +7 -2
- package/dist/store.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,8 @@ directory, git branch. Prompts, responses and file contents are never read.
|
|
|
97
97
|
|
|
98
98
|
The first time a window crosses a threshold (default `80` and `95`) you get a
|
|
99
99
|
desktop notification — once per threshold per window, re-armed when the window
|
|
100
|
-
resets. macOS
|
|
100
|
+
resets. macOS shows it from a small bundled UsageFleet app (allow it once when
|
|
101
|
+
macOS asks; a click opens the dashboard), Linux uses `notify-send` (falling back to
|
|
101
102
|
`kdialog --passivepopup`; install `libnotify-bin` if neither exists), Windows a
|
|
102
103
|
WinRT toast via `powershell.exe`.
|
|
103
104
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleIdentifier</key>
|
|
6
|
+
<string>dev.usagefleet.notifier</string>
|
|
7
|
+
<key>CFBundleName</key>
|
|
8
|
+
<string>UsageFleet</string>
|
|
9
|
+
<key>CFBundleDisplayName</key>
|
|
10
|
+
<string>UsageFleet</string>
|
|
11
|
+
<key>CFBundleExecutable</key>
|
|
12
|
+
<string>usagefleet-notifier</string>
|
|
13
|
+
<key>CFBundleIconFile</key>
|
|
14
|
+
<string>AppIcon</string>
|
|
15
|
+
<key>CFBundlePackageType</key>
|
|
16
|
+
<string>APPL</string>
|
|
17
|
+
<key>CFBundleShortVersionString</key>
|
|
18
|
+
<string>1.0</string>
|
|
19
|
+
<key>CFBundleVersion</key>
|
|
20
|
+
<string>1</string>
|
|
21
|
+
<key>LSMinimumSystemVersion</key>
|
|
22
|
+
<string>11.0</string>
|
|
23
|
+
<key>LSUIElement</key>
|
|
24
|
+
<true/>
|
|
25
|
+
</dict>
|
|
26
|
+
</plist>
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>files</key>
|
|
6
|
+
<dict>
|
|
7
|
+
<key>Resources/AppIcon.icns</key>
|
|
8
|
+
<data>
|
|
9
|
+
Qsu2PdOnquUAUG4LLkSUe3XpdqA=
|
|
10
|
+
</data>
|
|
11
|
+
</dict>
|
|
12
|
+
<key>files2</key>
|
|
13
|
+
<dict>
|
|
14
|
+
<key>Resources/AppIcon.icns</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>hash2</key>
|
|
17
|
+
<data>
|
|
18
|
+
yxmneUbIT7WvXoxaR+DXAKCF/pbzjexThOZ9bg8UK3E=
|
|
19
|
+
</data>
|
|
20
|
+
</dict>
|
|
21
|
+
</dict>
|
|
22
|
+
<key>rules</key>
|
|
23
|
+
<dict>
|
|
24
|
+
<key>^Resources/</key>
|
|
25
|
+
<true/>
|
|
26
|
+
<key>^Resources/.*\.lproj/</key>
|
|
27
|
+
<dict>
|
|
28
|
+
<key>optional</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>weight</key>
|
|
31
|
+
<real>1000</real>
|
|
32
|
+
</dict>
|
|
33
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
34
|
+
<dict>
|
|
35
|
+
<key>omit</key>
|
|
36
|
+
<true/>
|
|
37
|
+
<key>weight</key>
|
|
38
|
+
<real>1100</real>
|
|
39
|
+
</dict>
|
|
40
|
+
<key>^Resources/Base\.lproj/</key>
|
|
41
|
+
<dict>
|
|
42
|
+
<key>weight</key>
|
|
43
|
+
<real>1010</real>
|
|
44
|
+
</dict>
|
|
45
|
+
<key>^version.plist$</key>
|
|
46
|
+
<true/>
|
|
47
|
+
</dict>
|
|
48
|
+
<key>rules2</key>
|
|
49
|
+
<dict>
|
|
50
|
+
<key>.*\.dSYM($|/)</key>
|
|
51
|
+
<dict>
|
|
52
|
+
<key>weight</key>
|
|
53
|
+
<real>11</real>
|
|
54
|
+
</dict>
|
|
55
|
+
<key>^(.*/)?\.DS_Store$</key>
|
|
56
|
+
<dict>
|
|
57
|
+
<key>omit</key>
|
|
58
|
+
<true/>
|
|
59
|
+
<key>weight</key>
|
|
60
|
+
<real>2000</real>
|
|
61
|
+
</dict>
|
|
62
|
+
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
|
63
|
+
<dict>
|
|
64
|
+
<key>nested</key>
|
|
65
|
+
<true/>
|
|
66
|
+
<key>weight</key>
|
|
67
|
+
<real>10</real>
|
|
68
|
+
</dict>
|
|
69
|
+
<key>^.*</key>
|
|
70
|
+
<true/>
|
|
71
|
+
<key>^Info\.plist$</key>
|
|
72
|
+
<dict>
|
|
73
|
+
<key>omit</key>
|
|
74
|
+
<true/>
|
|
75
|
+
<key>weight</key>
|
|
76
|
+
<real>20</real>
|
|
77
|
+
</dict>
|
|
78
|
+
<key>^PkgInfo$</key>
|
|
79
|
+
<dict>
|
|
80
|
+
<key>omit</key>
|
|
81
|
+
<true/>
|
|
82
|
+
<key>weight</key>
|
|
83
|
+
<real>20</real>
|
|
84
|
+
</dict>
|
|
85
|
+
<key>^Resources/</key>
|
|
86
|
+
<dict>
|
|
87
|
+
<key>weight</key>
|
|
88
|
+
<real>20</real>
|
|
89
|
+
</dict>
|
|
90
|
+
<key>^Resources/.*\.lproj/</key>
|
|
91
|
+
<dict>
|
|
92
|
+
<key>optional</key>
|
|
93
|
+
<true/>
|
|
94
|
+
<key>weight</key>
|
|
95
|
+
<real>1000</real>
|
|
96
|
+
</dict>
|
|
97
|
+
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
|
98
|
+
<dict>
|
|
99
|
+
<key>omit</key>
|
|
100
|
+
<true/>
|
|
101
|
+
<key>weight</key>
|
|
102
|
+
<real>1100</real>
|
|
103
|
+
</dict>
|
|
104
|
+
<key>^Resources/Base\.lproj/</key>
|
|
105
|
+
<dict>
|
|
106
|
+
<key>weight</key>
|
|
107
|
+
<real>1010</real>
|
|
108
|
+
</dict>
|
|
109
|
+
<key>^[^/]+$</key>
|
|
110
|
+
<dict>
|
|
111
|
+
<key>nested</key>
|
|
112
|
+
<true/>
|
|
113
|
+
<key>weight</key>
|
|
114
|
+
<real>10</real>
|
|
115
|
+
</dict>
|
|
116
|
+
<key>^embedded\.provisionprofile$</key>
|
|
117
|
+
<dict>
|
|
118
|
+
<key>weight</key>
|
|
119
|
+
<real>20</real>
|
|
120
|
+
</dict>
|
|
121
|
+
<key>^version\.plist$</key>
|
|
122
|
+
<dict>
|
|
123
|
+
<key>weight</key>
|
|
124
|
+
<real>20</real>
|
|
125
|
+
</dict>
|
|
126
|
+
</dict>
|
|
127
|
+
</dict>
|
|
128
|
+
</plist>
|
package/dist/collector.js
CHANGED
|
@@ -18,6 +18,8 @@ import { fetchGroupLimits, postLimits, uploadBatch } from './uploader.js';
|
|
|
18
18
|
* uuids) and other JSONL — restricting to this subtree mirrors Claude Code and
|
|
19
19
|
* avoids re-uploading every desktop record twice. */
|
|
20
20
|
const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
|
|
21
|
+
/** `status` shows the cached reading's age, so it stays within this of the last check. */
|
|
22
|
+
const LIMITS_CACHE_REFRESH_MS = 10 * 60_000;
|
|
21
23
|
/**
|
|
22
24
|
* One full scan: tail every JSONL file from its stored offset, upload new usage
|
|
23
25
|
* records (chunked to batchSize), and commit each file's offset only after all
|
|
@@ -25,7 +27,7 @@ const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
|
|
|
25
27
|
*/
|
|
26
28
|
export async function runOnce(cfg, log = () => {
|
|
27
29
|
/* empty */
|
|
28
|
-
}) {
|
|
30
|
+
}, dirCache = new Map()) {
|
|
29
31
|
const { state } = readStore(cfg.storePath);
|
|
30
32
|
// Each scan root is tagged with the app that owns it. Claude Code's projects
|
|
31
33
|
// dir is scanned whole; the Claude Desktop sessions root is filtered to its
|
|
@@ -41,7 +43,7 @@ export async function runOnce(cfg, log = () => {
|
|
|
41
43
|
}
|
|
42
44
|
const files = [];
|
|
43
45
|
for (const root of roots) {
|
|
44
|
-
for (const fp of listJsonlFiles(root.dir)) {
|
|
46
|
+
for (const fp of listJsonlFiles(root.dir, dirCache)) {
|
|
45
47
|
if (root.onlyProjects && !fp.includes(PROJECTS_SUBPATH)) {
|
|
46
48
|
continue;
|
|
47
49
|
}
|
|
@@ -202,14 +204,17 @@ function chunks(segments, step) {
|
|
|
202
204
|
* Drop offsets for logs that no longer exist on disk, so a long-lived install
|
|
203
205
|
* does not grow its state file by one entry per Claude session forever. Only
|
|
204
206
|
* paths absent from this cycle's scan are stat'd, and only a real ENOENT prunes
|
|
205
|
-
* — a root that is merely unconfigured right now keeps its offsets.
|
|
207
|
+
* — a root that is merely unconfigured right now keeps its offsets. A path that
|
|
208
|
+
* still exists goes too when its inode is one this scan reached under another
|
|
209
|
+
* path: a root now read through a symlink (~/.pi -> .dotfiles/.pi).
|
|
206
210
|
* Returns whether anything was removed.
|
|
207
211
|
*/
|
|
208
212
|
function pruneMissingFiles(state, scanned) {
|
|
209
213
|
const seen = new Set(scanned.map(f => f.fp));
|
|
214
|
+
const seenInodes = new Set(scanned.map(f => state.files[f.fp]?.inode));
|
|
210
215
|
let removed = false;
|
|
211
|
-
for (const fp of Object.
|
|
212
|
-
if (seen.has(fp) || existsSync(fp)) {
|
|
216
|
+
for (const [fp, file] of Object.entries(state.files)) {
|
|
217
|
+
if (seen.has(fp) || (existsSync(fp) && !seenInodes.has(file.inode))) {
|
|
213
218
|
continue;
|
|
214
219
|
}
|
|
215
220
|
// oxlint-disable-next-line typescript/no-dynamic-delete -- state.files is a JSON blob keyed by path
|
|
@@ -282,8 +287,16 @@ export async function reportLimitsOnce(cfg, log = () => {
|
|
|
282
287
|
log('warn', 'limits upload failed · retrying next cycle');
|
|
283
288
|
}
|
|
284
289
|
// Cache the reading so `status` can show current usage without spending
|
|
285
|
-
// another billable API call.
|
|
290
|
+
// another billable API call. An unchanged reading refreshes its timestamp only
|
|
291
|
+
// every LIMITS_CACHE_REFRESH_MS: each save rewrites the whole store.
|
|
286
292
|
updateStore(cfg.storePath, store => {
|
|
293
|
+
const cached = store.limits;
|
|
294
|
+
const unchanged = cached?.fiveHourPct === report.fiveHourPct &&
|
|
295
|
+
cached.sevenDayPct === report.sevenDayPct &&
|
|
296
|
+
cached.source === report.source;
|
|
297
|
+
if (unchanged && Date.now() - Date.parse(cached.at) < LIMITS_CACHE_REFRESH_MS) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
287
300
|
store.limits = {
|
|
288
301
|
at: new Date().toISOString(),
|
|
289
302
|
fiveHourPct: report.fiveHourPct,
|
package/dist/index.js
CHANGED
|
@@ -96,13 +96,14 @@ async function cmdWatch() {
|
|
|
96
96
|
let stopping = false;
|
|
97
97
|
let timer = null;
|
|
98
98
|
let running = false;
|
|
99
|
+
const dirCache = new Map();
|
|
99
100
|
const tick = async () => {
|
|
100
101
|
if (stopping) {
|
|
101
102
|
return;
|
|
102
103
|
}
|
|
103
104
|
running = true;
|
|
104
105
|
try {
|
|
105
|
-
const r = await runOnce(cfg, stream);
|
|
106
|
+
const r = await runOnce(cfg, stream, dirCache);
|
|
106
107
|
// Dropped records are real data loss, so they must show up even in a
|
|
107
108
|
// cycle that uploaded nothing.
|
|
108
109
|
if (r.sent > 0 || r.dropped > 0) {
|
package/dist/notifier.js
CHANGED
|
@@ -18,17 +18,19 @@ export function loadNotifyConfig(env = process.env, file = readStore()) {
|
|
|
18
18
|
* Decide whether a window crosses a not-yet-notified threshold. Pure — no IO.
|
|
19
19
|
* Returns the threshold to fire (or null) and the next persisted state.
|
|
20
20
|
*
|
|
21
|
-
* A window rollover (resetsAt
|
|
21
|
+
* A window rollover (resetsAt moves) resets the high-water mark first, so the
|
|
22
22
|
* first crossing in a new window always re-notifies. If utilization later drops
|
|
23
23
|
* below the mark within the SAME window (e.g. a server correction), the mark is
|
|
24
24
|
* lowered so a subsequent re-cross notifies again.
|
|
25
25
|
*/
|
|
26
26
|
export function evaluateWindow(prev, pct, resetsAt, thresholds) {
|
|
27
|
-
const rolledOver = !prev || prev.resetsAt
|
|
27
|
+
const rolledOver = !prev || !sameWindow(prev.resetsAt, resetsAt);
|
|
28
28
|
const lastBucket = rolledOver ? 0 : prev.lastBucket;
|
|
29
|
+
// Within a window the first resetsAt stays, so the jitter alone never rewrites the store.
|
|
30
|
+
const windowResetsAt = rolledOver ? resetsAt : prev.resetsAt;
|
|
29
31
|
if (pct == null) {
|
|
30
32
|
// No reading this cycle — keep the mark, just track the (possibly new) window.
|
|
31
|
-
return { fire: null, next: { lastBucket, resetsAt } };
|
|
33
|
+
return { fire: null, next: { lastBucket, resetsAt: windowResetsAt } };
|
|
32
34
|
}
|
|
33
35
|
// Highest threshold the current pct has reached (thresholds are ascending).
|
|
34
36
|
let top = 0;
|
|
@@ -38,10 +40,17 @@ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
if (top > lastBucket) {
|
|
41
|
-
return { fire: top, next: { lastBucket: top, resetsAt } };
|
|
43
|
+
return { fire: top, next: { lastBucket: top, resetsAt: windowResetsAt } };
|
|
42
44
|
}
|
|
43
45
|
// top <= lastBucket: the mark follows pct down (or holds); nothing fires.
|
|
44
|
-
return { fire: null, next: { lastBucket: top, resetsAt } };
|
|
46
|
+
return { fire: null, next: { lastBucket: top, resetsAt: windowResetsAt } };
|
|
47
|
+
}
|
|
48
|
+
// oauth/usage jitters resets_at by milliseconds per request; a real rollover moves it by hours.
|
|
49
|
+
function sameWindow(a, b) {
|
|
50
|
+
if (a === null || b === null) {
|
|
51
|
+
return a === b;
|
|
52
|
+
}
|
|
53
|
+
return Math.abs(Date.parse(a) - Date.parse(b)) < 60_000;
|
|
45
54
|
}
|
|
46
55
|
/** Relative "resets in 12m" / "resets in 2h" suffix, or "" if unknown/past. */
|
|
47
56
|
function resetSuffix(resetsAt) {
|
package/dist/notify.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { cpSync, existsSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ENDPOINT } from './config.js';
|
|
6
|
+
import { notifierAppPath } from './service.js';
|
|
7
|
+
const NOTIFIER_EXE = join('Contents', 'MacOS', 'usagefleet-notifier');
|
|
2
8
|
/** Collapse every whitespace run (including the raw newlines AppleScript string
|
|
3
9
|
* literals forbid) to a single space, leaving hyphens and other punctuation
|
|
4
10
|
* intact. */
|
|
@@ -26,6 +32,36 @@ function spawnQuiet(cmd, args, onError) {
|
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
function notifyMac(title, message) {
|
|
35
|
+
// macos/build.sh puts the app next to this file in the npm release; dev runs have none.
|
|
36
|
+
const bundled = fileURLToPath(new URL('UsageFleet.app', import.meta.url));
|
|
37
|
+
if (!existsSync(bundled)) {
|
|
38
|
+
notifyOsascript(title, message);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const app = notifierAppPath();
|
|
42
|
+
installNotifierApp(bundled, app);
|
|
43
|
+
const child = spawn(join(app, NOTIFIER_EXE), [oneLine(title), oneLine(message), `${ENDPOINT}/dashboard`], {
|
|
44
|
+
// Own process group: launchd kills the collector's group when it restarts, and the
|
|
45
|
+
// first-run permission prompt keeps the app alive until the user answers it.
|
|
46
|
+
detached: true,
|
|
47
|
+
stdio: 'ignore',
|
|
48
|
+
});
|
|
49
|
+
child.on('error', () => notifyOsascript(title, message));
|
|
50
|
+
child.unref();
|
|
51
|
+
}
|
|
52
|
+
/** Copies the app to one fixed path, so macOS sees a single UsageFleet app whatever
|
|
53
|
+
* Node install runs the CLI. Replaces it only when the build changed. */
|
|
54
|
+
export function installNotifierApp(from, to) {
|
|
55
|
+
const exe = join(to, NOTIFIER_EXE);
|
|
56
|
+
if (existsSync(exe) && readFileSync(exe).equals(readFileSync(join(from, NOTIFIER_EXE)))) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const tmp = `${to}.${process.pid}.tmp`;
|
|
60
|
+
cpSync(from, tmp, { recursive: true });
|
|
61
|
+
rmSync(to, { recursive: true, force: true });
|
|
62
|
+
renameSync(tmp, to);
|
|
63
|
+
}
|
|
64
|
+
function notifyOsascript(title, message) {
|
|
29
65
|
// execFile (no shell) — the only interpolation surface is the AppleScript
|
|
30
66
|
// string, which osaEscape neutralizes.
|
|
31
67
|
const script = `display notification "${osaEscape(message)}" with title "${osaEscape(title)}"`;
|
|
@@ -64,7 +100,7 @@ function notifyWindows(title, message) {
|
|
|
64
100
|
/**
|
|
65
101
|
* Show a desktop notification. Best-effort and non-blocking: it never throws and
|
|
66
102
|
* never blocks the caller. Supported platforms:
|
|
67
|
-
* - macOS: `osascript`
|
|
103
|
+
* - macOS: the bundled UsageFleet.app, or `osascript` when it is missing.
|
|
68
104
|
* - Linux: `notify-send` (KDE Plasma + other freedesktop daemons), falling
|
|
69
105
|
* back to `kdialog --passivepopup` on KDE.
|
|
70
106
|
* - Windows: WinRT toast via `powershell.exe` -> Action Center.
|
package/dist/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
// USAGEFLEET_PROJECTS is resolved in config.ts's precedence chain, not here —
|
|
@@ -42,7 +43,25 @@ export function defaultPiSessionsDirs() {
|
|
|
42
43
|
if (agent) {
|
|
43
44
|
dirs.push(join(agent, 'sessions'));
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
// pi exports its dir resolved (~/.pi -> .dotfiles/.pi gives the .dotfiles path), so one
|
|
47
|
+
// root can arrive twice. The first path wins: it is the one the service, without pi's env, scans.
|
|
48
|
+
const unique = new Map();
|
|
49
|
+
for (const dir of dirs) {
|
|
50
|
+
const real = realDir(dir);
|
|
51
|
+
if (!unique.has(real)) {
|
|
52
|
+
unique.set(real, dir);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...unique.values()];
|
|
56
|
+
}
|
|
57
|
+
function realDir(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(dir);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Missing roots are normal (pi never ran here) and scan to nothing.
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
46
65
|
}
|
|
47
66
|
/** pi's agent dir, the root for its sessions and extensions.
|
|
48
67
|
* PI_CODING_AGENT_DIR is pi's own relocation knob; unlike the service-side
|
package/dist/release.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by .github/workflows/release.yml.
|
|
2
|
-
export const RELEASE_VERSION = "1.2.
|
|
2
|
+
export const RELEASE_VERSION = "1.2.153";
|
package/dist/scanner.js
CHANGED
|
@@ -1,24 +1,58 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
// Linux stamps mtimes with a jiffy-coarse clock, HFS+ by the second, FAT by two: a change
|
|
4
|
+
// right after a read can carry the listing's mtime. Only a folder quiet this long is trusted.
|
|
5
|
+
const QUIET_MS = 10_000;
|
|
3
6
|
/** Recursively list all *.jsonl files under a directory. Returns [] if the
|
|
4
|
-
* directory is missing.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
* directory is missing. A folder whose mtime has not moved reuses its listing
|
|
8
|
+
* from `cache`: adding or removing an entry moves it, appending to a log does not. */
|
|
9
|
+
export function listJsonlFiles(dir, cache = new Map()) {
|
|
10
|
+
const listing = listDir(dir, cache);
|
|
11
|
+
const out = [...listing.files];
|
|
12
|
+
for (const sub of listing.dirs) {
|
|
13
|
+
out.push(...listJsonlFiles(sub, cache));
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
function listDir(dir, cache) {
|
|
18
|
+
const cached = cache.get(dir);
|
|
19
|
+
const readAt = Date.now();
|
|
20
|
+
let mtimeMs;
|
|
7
21
|
let entries;
|
|
8
22
|
try {
|
|
23
|
+
mtimeMs = statSync(dir).mtimeMs;
|
|
24
|
+
if (cached?.mtimeMs === mtimeMs) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
9
27
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
10
28
|
}
|
|
11
29
|
catch {
|
|
12
|
-
|
|
30
|
+
forget(cache, dir);
|
|
31
|
+
return { dirs: [], files: [], mtimeMs: null };
|
|
13
32
|
}
|
|
33
|
+
const listing = { dirs: [], files: [], mtimeMs: readAt - mtimeMs > QUIET_MS ? mtimeMs : null };
|
|
14
34
|
for (const e of entries) {
|
|
15
35
|
const full = join(dir, e.name);
|
|
16
36
|
if (e.isDirectory()) {
|
|
17
|
-
|
|
37
|
+
listing.dirs.push(full);
|
|
18
38
|
}
|
|
19
39
|
else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
20
|
-
|
|
40
|
+
listing.files.push(full);
|
|
21
41
|
}
|
|
22
42
|
}
|
|
23
|
-
|
|
43
|
+
const kept = new Set(listing.dirs);
|
|
44
|
+
for (const sub of cached?.dirs ?? []) {
|
|
45
|
+
if (!kept.has(sub)) {
|
|
46
|
+
forget(cache, sub);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
cache.set(dir, listing);
|
|
50
|
+
return listing;
|
|
51
|
+
}
|
|
52
|
+
function forget(cache, dir) {
|
|
53
|
+
const listing = cache.get(dir);
|
|
54
|
+
cache.delete(dir);
|
|
55
|
+
for (const sub of listing?.dirs ?? []) {
|
|
56
|
+
forget(cache, sub);
|
|
57
|
+
}
|
|
24
58
|
}
|
package/dist/service.js
CHANGED
|
@@ -15,7 +15,7 @@ const TASK = 'usagefleet';
|
|
|
15
15
|
* service that lost it would silently report the default account instead. */
|
|
16
16
|
const EXTRA_PASSTHROUGH_ENV = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR']);
|
|
17
17
|
/** Per-user dir for the collector's own runtime files: the Windows launcher and
|
|
18
|
-
* its log, plus the binary copy that pre-npm releases left there. */
|
|
18
|
+
* its log, the macOS notifier app, plus the binary copy that pre-npm releases left there. */
|
|
19
19
|
function stableBinDir() {
|
|
20
20
|
if (process.platform === 'darwin') {
|
|
21
21
|
return join(homedir(), 'Library', 'Application Support', 'usagefleet');
|
|
@@ -39,6 +39,10 @@ function macLogDir() {
|
|
|
39
39
|
function windowsVbsPath() {
|
|
40
40
|
return join(stableBinDir(), 'usagefleet-watch.vbs');
|
|
41
41
|
}
|
|
42
|
+
/** One fixed path for the macOS notifier, whatever Node install runs the CLI. */
|
|
43
|
+
export function notifierAppPath() {
|
|
44
|
+
return join(stableBinDir(), 'UsageFleet.app');
|
|
45
|
+
}
|
|
42
46
|
/** Where releases before the npm switch parked their copy of the binary. Only
|
|
43
47
|
* cleanup touches it now. */
|
|
44
48
|
function stableBinPath() {
|
|
@@ -553,7 +557,8 @@ export function uninstall() {
|
|
|
553
557
|
}
|
|
554
558
|
}
|
|
555
559
|
removeStableBin();
|
|
556
|
-
|
|
560
|
+
rmSync(notifierAppPath(), { force: true, recursive: true });
|
|
561
|
+
console.log(step('removed', 'launchd agent · notification app'));
|
|
557
562
|
console.log(row('leftover', `${tilde(path)} · delete to fully clean up`));
|
|
558
563
|
return;
|
|
559
564
|
}
|
package/dist/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { writeFileAtomic } from './atomic-write.js';
|
|
@@ -108,7 +108,13 @@ export function readStore(path = storePath()) {
|
|
|
108
108
|
*/
|
|
109
109
|
export function updateStore(path, mutate) {
|
|
110
110
|
const store = readStore(path);
|
|
111
|
+
const before = JSON.stringify(store);
|
|
111
112
|
mutate(store);
|
|
113
|
+
// The offsets make the store ~0.5 MB, so an unchanged one is not rewritten. A missing
|
|
114
|
+
// file always is: it pins the deviceId normalize() just generated and folds the legacy files.
|
|
115
|
+
if (existsSync(path) && JSON.stringify(store) === before) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
112
118
|
mkdirSync(dirname(path), { recursive: true });
|
|
113
119
|
// 0600: the file holds the device token.
|
|
114
120
|
writeFileAtomic(path, `${JSON.stringify(store, null, 2)}\n`, 0o600);
|