@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.
package/dist/tailer.js ADDED
@@ -0,0 +1,65 @@
1
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
2
+ import { parseLine } from './parser.js';
3
+ /** Max bytes read from a single file per cycle (bounds memory on huge backlogs). */
4
+ const MAX_READ = 16 * 1024 * 1024;
5
+ /**
6
+ * Read new, newline-terminated lines from `filePath` starting at the previously
7
+ * stored offset. Handles rotation/truncation (inode change or size < offset →
8
+ * restart from 0) and partial trailing lines (never consume past the last \n).
9
+ */
10
+ export function tailFile(filePath, prev, source = 'cli') {
11
+ let st;
12
+ try {
13
+ st = statSync(filePath);
14
+ }
15
+ catch {
16
+ return null; // file vanished
17
+ }
18
+ const rotated = prev !== undefined && (st.ino !== prev.inode || st.size < prev.offset);
19
+ const start = rotated || prev === undefined ? 0 : prev.offset;
20
+ const base = { inode: Number(st.ino), offset: start };
21
+ if (st.size <= start) {
22
+ return { consumedBytes: 0, nextState: base, records: [] };
23
+ }
24
+ // Cap the per-cycle read so a huge backlog can't OOM; the next cycle resumes
25
+ // from the committed offset.
26
+ const length = Math.min(st.size - start, MAX_READ);
27
+ const buf = Buffer.alloc(length);
28
+ const fd = openSync(filePath, 'r');
29
+ try {
30
+ readSync(fd, buf, 0, length, start);
31
+ }
32
+ finally {
33
+ closeSync(fd);
34
+ }
35
+ // Only consume up to the last newline; keep any partial trailing line.
36
+ const lastNl = buf.lastIndexOf(0x0a);
37
+ if (lastNl === -1) {
38
+ // No newline in a full MAX_READ window = one pathologically long line.
39
+ // Skip past it so the file can't stall forever.
40
+ if (length >= MAX_READ) {
41
+ console.warn(`usagefleet: skipping a line > ${MAX_READ} bytes in ${filePath} at offset ${start}`);
42
+ return {
43
+ consumedBytes: length,
44
+ nextState: { ...base, offset: start + length },
45
+ records: [],
46
+ };
47
+ }
48
+ return { consumedBytes: 0, nextState: base, records: [] };
49
+ }
50
+ const consumed = buf.subarray(0, lastNl + 1);
51
+ const text = consumed.toString('utf-8');
52
+ const records = [];
53
+ for (const line of text.split('\n')) {
54
+ const rec = parseLine(line, source);
55
+ if (rec) {
56
+ records.push(rec);
57
+ }
58
+ }
59
+ const consumedBytes = consumed.length;
60
+ return {
61
+ consumedBytes,
62
+ nextState: { ...base, offset: start + consumedBytes },
63
+ records,
64
+ };
65
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Terminal output helpers, "quiet" style: one accent colour, detail in gray,
3
+ * a padded label column so lines align.
4
+ *
5
+ * Colour is dropped when stdout is not a TTY (service logs, CI, pipes) or when
6
+ * NO_COLOR is set. Bars are plain characters, so a log file still shows them.
7
+ */
8
+ const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
9
+ function paint(code) {
10
+ return s => (useColor ? `\u001B[${code}m${s}\u001B[0m` : s);
11
+ }
12
+ export const dim = paint('90');
13
+ export const green = paint('32');
14
+ export const yellow = paint('33');
15
+ export const red = paint('31');
16
+ export const blue = paint('34');
17
+ export const bold = paint('1');
18
+ /** Width of the label column shared by `step` and `row`. */
19
+ const LABEL = 10;
20
+ /** "✓ installed ~/.local/bin/usagefleet" — a completed step. */
21
+ export function step(label, detail = '') {
22
+ return `${green('✓')} ${label.padEnd(LABEL)} ${dim(detail)}`;
23
+ }
24
+ /** "● service running" — state with a health-coloured dot. */
25
+ export function state(health, label, detail) {
26
+ const dot = health === 'ok' ? green('●') : health === 'warn' ? yellow('●') : red('●');
27
+ return `${dot} ${label.padEnd(LABEL)} ${detail}`;
28
+ }
29
+ /** " config ~/.config/usagefleet/config.json" — a plain detail line. */
30
+ export function row(label, detail) {
31
+ return ` ${label.padEnd(LABEL)} ${dim(detail)}`;
32
+ }
33
+ /** Percentage as a fixed-width string, so successive log lines line up. */
34
+ export function pct(value) {
35
+ return `${value ?? '?'}%`.padStart(4);
36
+ }
37
+ /** Usage bar, coloured by how close the window is to its limit.
38
+ * Unknown usage renders as an empty bar rather than a missing column. */
39
+ export function bar(value, width = 10) {
40
+ if (value === null) {
41
+ return dim('░'.repeat(width));
42
+ }
43
+ // Any real usage lights at least one cell: an empty bar means zero, nothing else.
44
+ const cells = Math.round((value / 100) * width);
45
+ const filled = value > 0 ? Math.max(1, Math.min(width, cells)) : 0;
46
+ const fill = value >= 95 ? red : value >= 80 ? yellow : green;
47
+ return fill('█'.repeat(filled)) + dim('░'.repeat(width - filled));
48
+ }
49
+ /** "2m ago" / "3d ago" — compact age of an ISO timestamp. */
50
+ export function ago(iso) {
51
+ if (!iso) {
52
+ return 'never';
53
+ }
54
+ const ms = Date.now() - new Date(iso).getTime();
55
+ if (!Number.isFinite(ms) || ms < 0) {
56
+ return 'unknown';
57
+ }
58
+ const s = Math.round(ms / 1000);
59
+ if (s < 60) {
60
+ return `${s}s ago`;
61
+ }
62
+ if (s < 3600) {
63
+ return `${Math.round(s / 60)}m ago`;
64
+ }
65
+ if (s < 86_400) {
66
+ return `${Math.round(s / 3600)}h ago`;
67
+ }
68
+ return `${Math.round(s / 86_400)}d ago`;
69
+ }
package/dist/update.js ADDED
@@ -0,0 +1,100 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { RELEASE_VERSION } from './release.js';
5
+ /** The published package: one artifact for every OS, installed with
6
+ * `npm i -g @usagefleet/cli`. */
7
+ const PACKAGE = '@usagefleet/cli';
8
+ /** Public registry, so self-update needs no server, no token and no GitHub
9
+ * credential — and npm verifies the tarball's integrity on the way in. */
10
+ const REGISTRY = 'https://registry.npmjs.org';
11
+ /** Semver in the shape npm accepts on a command line. The registry is remote
12
+ * input that ends up in an `npm install` argv (and on Windows, in a shell), so
13
+ * anything unexpected is dropped rather than passed along. */
14
+ const VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
15
+ /** npm from the same install as the node running us. A launchd/systemd service
16
+ * gets a minimal PATH that rarely has the user's node manager on it, so the
17
+ * bare name is only a fallback. */
18
+ function npmCommand() {
19
+ const sibling = join(dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
20
+ return existsSync(sibling) ? sibling : 'npm';
21
+ }
22
+ /** Exit code of a finished child, or null when it could not be started. */
23
+ function run(cmd, args) {
24
+ return new Promise(resolve => {
25
+ // shell on Windows: node refuses to spawn a .cmd directly since the 2024
26
+ // argument-injection fix. Every argument here is a literal or VERSION-checked.
27
+ const child = spawn(cmd, args, { shell: process.platform === 'win32', stdio: 'ignore' });
28
+ child.on('error', () => resolve(null));
29
+ child.on('close', code => resolve(code));
30
+ });
31
+ }
32
+ /**
33
+ * Upgrade the collector in place via npm and restart the service on the new
34
+ * version. Returns the version installed, or null when there was nothing to do.
35
+ *
36
+ * Every failure path is a silent no-op — a self-updater that breaks a working
37
+ * install is worse than one that skips a release. `force` is the manual
38
+ * `usagefleet update`, which ignores USAGEFLEET_UPDATE=0 but still refuses to
39
+ * touch a dev build.
40
+ */
41
+ export async function checkForUpdate(log, force = false) {
42
+ if (RELEASE_VERSION === 'dev') {
43
+ if (force) {
44
+ log('update: this is a dev build — install the published package first.');
45
+ }
46
+ return null;
47
+ }
48
+ // The service is restarted by re-invoking this script, so without a path to
49
+ // it there is nothing to hand over to.
50
+ const self = process.argv[1];
51
+ if (!self) {
52
+ return null;
53
+ }
54
+ if (!force && process.env.USAGEFLEET_UPDATE === '0') {
55
+ return null;
56
+ }
57
+ // A rejected fetch (offline, DNS, timeout) must stay inside this function:
58
+ // `watch` calls it mid-cycle, so a thrown error would abort the rest of that
59
+ // cycle — the limits report included — on every tick the registry is down.
60
+ let latest;
61
+ try {
62
+ const res = await fetch(`${REGISTRY}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(15_000) });
63
+ if (!res.ok) {
64
+ if (force) {
65
+ log(`update: registry has no release info (${res.status}).`);
66
+ }
67
+ return null;
68
+ }
69
+ latest = (await res.json()).version;
70
+ }
71
+ catch (error) {
72
+ if (force) {
73
+ log(`update: cannot reach the npm registry (${error.message}).`);
74
+ }
75
+ return null;
76
+ }
77
+ if (!latest || !VERSION.test(latest)) {
78
+ return null;
79
+ }
80
+ if (latest === RELEASE_VERSION) {
81
+ if (force) {
82
+ log(`update: already on ${RELEASE_VERSION}.`);
83
+ }
84
+ return null;
85
+ }
86
+ log(`update: ${RELEASE_VERSION} → ${latest}, installing ${PACKAGE}…`);
87
+ const code = await run(npmCommand(), ['install', '--global', `${PACKAGE}@${latest}`]);
88
+ if (code !== 0) {
89
+ log(code === null
90
+ ? 'update: npm is not available — reinstall with `npm i -g @usagefleet/cli`.'
91
+ : `update: npm install failed (exit ${code}) — if the global prefix needs root, run it yourself.`);
92
+ return null;
93
+ }
94
+ // Detached: `install` rewrites the service definition and restarts it, which
95
+ // kills this process tree. npm replaced the file behind `self`, so this is
96
+ // already the new version.
97
+ spawn(process.execPath, [self, 'install'], { detached: true, stdio: 'ignore' }).unref();
98
+ log(`update: installed ${latest}, restarting service.`);
99
+ return latest;
100
+ }
@@ -0,0 +1,93 @@
1
+ const MAX_ATTEMPTS = 6;
2
+ const REQUEST_TIMEOUT_MS = 15_000;
3
+ function sleep(ms) {
4
+ return new Promise(resolve => {
5
+ setTimeout(resolve, ms);
6
+ });
7
+ }
8
+ /**
9
+ * POST a batch with exponential backoff + jitter. Returns ok on 2xx, otherwise a
10
+ * classified failure so the caller advances or retains the file offset correctly.
11
+ */
12
+ export async function uploadBatch(payload, cfg) {
13
+ let delay = 1000;
14
+ for (let attempt = 0; attempt <= MAX_ATTEMPTS; attempt++) {
15
+ let res = null;
16
+ try {
17
+ res = await fetch(`${cfg.endpoint}/api/v1/usage`, {
18
+ body: JSON.stringify(payload),
19
+ headers: {
20
+ 'content-type': 'application/json',
21
+ 'x-api-key': cfg.token,
22
+ },
23
+ method: 'POST',
24
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
25
+ });
26
+ }
27
+ catch {
28
+ res = null; // network error / timeout → retry
29
+ }
30
+ if (res && res.ok) {
31
+ const body = (await res.json().catch(() => ({})));
32
+ return { ok: true, ...body };
33
+ }
34
+ // Non-retryable 4xx (except 429): no amount of retrying inside this cycle
35
+ // changes the answer, so classify and hand it back to the caller now.
36
+ if (res && res.status >= 400 && res.status < 500 && res.status !== 429) {
37
+ return { fatal: classifyClientError(res.status), ok: false };
38
+ }
39
+ const fallback = Math.min(delay, 60_000) + Math.floor(Math.random() * 500);
40
+ if (attempt < MAX_ATTEMPTS) {
41
+ await sleep(retryAfterMs(res?.headers.get('retry-after'), fallback));
42
+ }
43
+ delay *= 2;
44
+ }
45
+ return { fatal: 'transient', ok: false };
46
+ }
47
+ /** Map a 4xx (never 429, the caller filters it) to a failure kind. */
48
+ function classifyClientError(status) {
49
+ if (status === 401 || status === 403) {
50
+ return 'auth';
51
+ }
52
+ if (status === 400 || status === 422) {
53
+ return 'invalid';
54
+ }
55
+ return 'transient'; // 402 outside plan, 404, 408, 413, … — the data is fine
56
+ }
57
+ /** Parse a Retry-After header (delta-seconds OR HTTP-date), clamped to [0, 60s]. */
58
+ function retryAfterMs(header, fallback) {
59
+ if (!header) {
60
+ return fallback;
61
+ }
62
+ let wait;
63
+ const secs = Number(header);
64
+ if (Number.isFinite(secs)) {
65
+ wait = secs * 1000;
66
+ }
67
+ else {
68
+ const when = Date.parse(header);
69
+ if (!Number.isFinite(when)) {
70
+ return fallback;
71
+ }
72
+ wait = when - Date.now();
73
+ }
74
+ return Math.min(Math.max(wait, 0), 60_000) + Math.floor(Math.random() * 500);
75
+ }
76
+ /** Report the account's real limit utilization to the server. */
77
+ export async function postLimits(report, cfg) {
78
+ try {
79
+ const res = await fetch(`${cfg.endpoint}/api/v1/limits`, {
80
+ body: JSON.stringify(report),
81
+ headers: {
82
+ 'content-type': 'application/json',
83
+ 'x-api-key': cfg.token,
84
+ },
85
+ method: 'POST',
86
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
87
+ });
88
+ return res.ok;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@usagefleet/cli",
3
+ "version": "1.2.59",
4
+ "description": "Tails Claude Code, Claude Desktop, and pi agent JSONL logs and reports token usage to a UsageFleet server.",
5
+ "keywords": [
6
+ "claude",
7
+ "claude-code",
8
+ "telemetry",
9
+ "tokens",
10
+ "usage"
11
+ ],
12
+ "homepage": "https://usagefleet.com",
13
+ "license": "MIT",
14
+ "bin": {
15
+ "usagefleet": "dist/index.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "type": "module",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "dev": "bun run src/index.ts",
27
+ "start": "node dist/index.js",
28
+ "test": "vitest run",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^24",
33
+ "typescript": "^7",
34
+ "vitest": "^4"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ }
39
+ }