usagemax 0.3.2 → 0.3.3

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 CHANGED
@@ -124,6 +124,47 @@ It never automatically replays an old authoritative deletion against newer data.
124
124
  Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
125
125
  self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
126
126
 
127
+ ## Optional automatic sync
128
+
129
+ Automatic sync is **off by default**. Install a persistent CLI first (a bunx/npx
130
+ cache can disappear), link the computer if needed, then opt in:
131
+
132
+ ```bash
133
+ bun install -g usagemax
134
+ usagemax service install # approximately every 15 minutes
135
+ usagemax service install --every 30 # change interval; 5–1440 minutes
136
+ usagemax service status # scheduler reachability + last result
137
+ usagemax service run # run now, respecting failure backoff
138
+ usagemax service uninstall # stop future jobs; retain account/data
139
+ ```
140
+
141
+ Uses a user LaunchAgent on macOS, a user systemd timer on Linux/WSL, and Task
142
+ Scheduler on Windows. No admin/root access, resident daemon, file watcher,
143
+ automatic package updates, or package downloads per run. Each job runs the normal
144
+ incremental sync and exits. No-change runs skip parsing/uploading when the source
145
+ inventory is complete and unchanged. The metadata inventory still costs disk I/O;
146
+ an active or first/full-history scan costs more. This is periodic, not live telemetry.
147
+
148
+ Schedules are spread by up to a minute. macOS/Linux jobs have reduced CPU/I/O
149
+ priority. Windows jobs require a signed-in user and defer starting on battery.
150
+ Jobs do not wake a sleeping computer. Linux needs a running systemd user manager;
151
+ WSL must already be running with systemd enabled. UsageMax does not enable linger
152
+ or keep a WSL distro alive. Missed intervals are not replayed as a backlog.
153
+
154
+ The existing collector lock prevents overlapping uploads. Failures back off
155
+ exponentially (up to six hours); manually running `usagemax sync` is available for
156
+ diagnosis without waiting. `service-state.json` retains only the latest bounded
157
+ status, timestamps, duration, and failure count, not raw logs or credentials.
158
+ If a process was forcibly killed, inspect the PID reported by `sync` before
159
+ removing its stale `collector.lock`; never delete `config.json` to retry.
160
+
161
+ Re-run `service install` after upgrading/moving the CLI or changing source-path
162
+ environment variables. Only an explicit allowlist of discovery settings is saved,
163
+ not your shell's secrets. Jobs run from your home directory; repository-local
164
+ `.ccusage/ccusage.json` configuration is not automatically used. Keep the runtime
165
+ and global CLI installed. `service uninstall` leaves existing usage, link credentials,
166
+ checkpoints, and last-run status intact; an in-flight sync may finish.
167
+
127
168
  ## Interrupted uploads and protocol 0.3.1
128
169
 
129
170
  Before uploading, the CLI saves the exact run, ordered request payloads and next
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "license": "MIT",
6
6
  "author": "UsageMax",
@@ -25,6 +25,7 @@
25
25
  "src/sources.js",
26
26
  "src/transport.js",
27
27
  "src/resume.js",
28
+ "src/service.js",
28
29
  "README.md",
29
30
  "LICENSE"
30
31
  ],
package/src/cli.js CHANGED
@@ -8,17 +8,19 @@ import { homedir, platform } from "node:os";
8
8
  import { dirname, join } from "node:path";
9
9
  import process from "node:process";
10
10
  import { promisify } from "node:util";
11
+ import { fileURLToPath } from "node:url";
11
12
 
12
13
  import { prepareArchiveRecovery } from "./archives.js";
13
14
  import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
14
15
  import { stableInstallationId } from "./installation.js";
16
+ import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
15
17
  import { requestSnapshot } from "./transport.js";
16
18
  import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
17
19
  import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
18
20
 
19
21
  const require = createRequire(import.meta.url);
20
22
  const executeFile = promisify(execFile);
21
- const VERSION = "0.3.2";
23
+ const VERSION = "0.3.3";
22
24
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
23
25
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
24
26
  const CONFIG_FILE = "config.json";
@@ -107,6 +109,9 @@ function help() {
107
109
  process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
108
110
  process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
109
111
  process.stdout.write(" usagemax status Show local link status\n");
112
+ process.stdout.write(" usagemax service install [--every 15]\n");
113
+ process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
114
+ process.stdout.write(" usagemax service status|run|uninstall\n");
110
115
  process.stdout.write(" usagemax doctor [--deep] [--json]\n");
111
116
  process.stdout.write(" Check source coverage; --deep parses full history\n");
112
117
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
@@ -127,6 +132,8 @@ async function ccusageJson(config, { full = false, env } = {}) {
127
132
  const { stdout } = await executeFile(process.execPath, args, {
128
133
  encoding: "utf8",
129
134
  maxBuffer: MAX_REPORT_BYTES,
135
+ timeout: 10 * 60 * 1000,
136
+ killSignal: "SIGKILL",
130
137
  env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
131
138
  });
132
139
  return JSON.parse(stdout);
@@ -446,6 +453,26 @@ async function main() {
446
453
  const command = args[0] || "sync";
447
454
  if (["--help", "-h", "help"].includes(command)) return help();
448
455
  if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
456
+ if (command === "service") {
457
+ const action = args[1] || "status";
458
+ const directory = option(args, "--config-dir") || configDirectory();
459
+ process.env.USAGEMAX_CONFIG_DIR = directory;
460
+ let result;
461
+ if (action === "run") {
462
+ result = await runScheduledSync(directory, () => withConfigLock(directory, () => sync(["--json"])));
463
+ if (result.status === "error") process.exitCode = 1;
464
+ } else if (action === "status") {
465
+ result = await manageService(action, { directory, cli: fileURLToPath(import.meta.url) });
466
+ } else {
467
+ result = await withConfigLock(directory, async () => {
468
+ if (action === "install" && !await readConfig()) throw new Error("Link this computer before enabling automatic sync.");
469
+ if (args.includes("--every") && option(args, "--every") === undefined) throw new Error("--every requires a number of minutes.");
470
+ return manageService(action, { directory, cli: fileURLToPath(import.meta.url), minutes: args.includes("--every") ? intervalMinutes(option(args, "--every")) : undefined });
471
+ });
472
+ }
473
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
474
+ return;
475
+ }
449
476
  if (command === "report") return report(args.slice(1));
450
477
  if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
451
478
  return withConfigLock(configDirectory(), async () => {
package/src/resume.js CHANGED
@@ -48,7 +48,9 @@ export async function withConfigLock(directory, action) {
48
48
  } catch (error) {
49
49
  if (error.code !== "EEXIST") throw error;
50
50
  const owner = (await readFile(path, "utf8").catch(() => "unknown")).trim();
51
- throw new Error(`Collector config is locked by PID ${/^\d+$/.test(owner) ? owner : "unknown"}. If that process has exited, remove only ${path} and rerun sync; keep config.json for resume.`);
51
+ const busy = new Error(`Collector config is locked by PID ${/^\d+$/.test(owner) ? owner : "unknown"}. If that process has exited, remove only ${path} and rerun sync; keep config.json for resume.`);
52
+ busy.code = "USAGEMAX_BUSY";
53
+ throw busy;
52
54
  }
53
55
  try {
54
56
  return await action();
package/src/service.js ADDED
@@ -0,0 +1,150 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { access, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+
8
+ const execute = promisify(execFile);
9
+ const ENV_KEYS = ["PATH", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA", "WSL_DISTRO_NAME", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "OPENCODE_DATA_DIR", "AMP_DATA_DIR", "DROID_SESSIONS_DIR", "CODEBUFF_DATA_DIR", "HERMES_HOME", "PI_AGENT_DIR", "GOOSE_PATH_ROOT", "OPENCLAW_DIR", "KILO_DATA_DIR", "KIMI_DATA_DIR", "QWEN_DATA_DIR", "GEMINI_DATA_DIR", "GROK_HOME", "COPILOT_OTEL_FILE_EXPORTER_PATH", "USAGEMAX_ADDITIONAL_HOME", "USAGEMAX_WSL_USERS_DIR", "CCUSAGE_MODEL_ALIASES"];
10
+ const xml = (value) => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
11
+ const unitQuote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", () => "$$")}"`;
12
+ const windowsQuote = (value) => `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
13
+
14
+ export function intervalMinutes(value = 15) {
15
+ if (!/^\d+$/.test(String(value)) || Number(value) < 5 || Number(value) > 1440) throw new Error("--every must be an integer from 5 to 1440 minutes.");
16
+ return Number(value);
17
+ }
18
+
19
+ export function capturedEnvironment(env) {
20
+ // Never persist the whole shell environment: it can contain provider secrets.
21
+ return Object.fromEntries(ENV_KEYS.filter((key) => typeof env[key] === "string").map((key) => [key, env[key]]));
22
+ }
23
+
24
+ export function assertDurablePath(path) {
25
+ if (!isAbsolute(path) || /[\x00-\x1f]/.test(path) || /\/(?:_npx|cache|fnm_multishells)\//i.test(path.replaceAll("\\", "/"))) {
26
+ throw new Error("Automatic sync needs a persistent installation. Run `bun install -g usagemax`, then `usagemax service install`; do not install a scheduler from bunx/npx caches.");
27
+ }
28
+ }
29
+
30
+ export function servicePlan({ directory, executable, cli, minutes = 15, platform = process.platform, home = homedir(), uid = process.getuid?.(), configHome = join(home, ".config"), now = Date.now() }) {
31
+ minutes = intervalMinutes(minutes);
32
+ for (const path of [directory, executable, cli, home, configHome]) if (/[\x00-\x1f]/.test(path)) throw new Error("Scheduler paths cannot contain control characters.");
33
+ const id = createHash("sha256").update(directory).digest("hex").slice(0, 12);
34
+ const name = `com.usagemax.sync.${id}`;
35
+ const args = [cli, "service", "run", "--config-dir", directory];
36
+ const command = [executable, ...args];
37
+ if (platform === "darwin") {
38
+ const file = join(home, "Library", "LaunchAgents", `${name}.plist`);
39
+ const domain = `gui/${uid}`;
40
+ return { backend: "launchd", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
41
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
42
+ <plist version="1.0"><dict><key>Label</key><string>${name}</string>
43
+ <key>ProgramArguments</key><array>${command.map((s) => `<string>${xml(s)}</string>`).join("")}</array>
44
+ <key>WorkingDirectory</key><string>${xml(home)}</string>
45
+ <key>StartInterval</key><integer>${minutes * 60 + parseInt(id.slice(0, 4), 16) % 60}</integer>
46
+ <key>ProcessType</key><string>Background</string><key>Nice</key><integer>10</integer>
47
+ <key>LowPriorityIO</key><true/><key>StandardOutPath</key><string>/dev/null</string>
48
+ <key>StandardErrorPath</key><string>/dev/null</string></dict></plist>
49
+ `]], probe: ["launchctl", ["print", `${domain}/${name}`]], install: [["launchctl", ["bootstrap", domain, file]], ["launchctl", ["enable", `${domain}/${name}`]]], uninstall: [["launchctl", ["bootout", `${domain}/${name}`]]] };
50
+ }
51
+ if (platform === "linux") {
52
+ const root = join(configHome, "systemd", "user");
53
+ return { backend: "systemd", name, files: [
54
+ [join(root, `${name}.service`), `[Unit]\nDescription=UsageMax incremental usage sync\n[Service]\nType=oneshot\nExecStart=${command.map(unitQuote).join(" ")}\nWorkingDirectory=${unitQuote(home)}\nNice=10\nIOSchedulingClass=idle\nTimeoutStartSec=infinity\nStandardOutput=null\nStandardError=null\n`],
55
+ [join(root, `${name}.timer`), `[Unit]\nDescription=UsageMax automatic sync\n[Timer]\nOnActiveSec=1m\nOnUnitInactiveSec=${minutes}m\nRandomizedDelaySec=60\nAccuracySec=30s\n[Install]\nWantedBy=timers.target\n`],
56
+ ], probe: ["systemctl", ["--user", "is-active", `${name}.timer`]], install: [["systemctl", ["--user", "daemon-reload"]], ["systemctl", ["--user", "enable", "--now", `${name}.timer`]]], uninstall: [["systemctl", ["--user", "disable", "--now", `${name}.timer`]]] };
57
+ }
58
+ if (platform === "win32") {
59
+ const file = join(directory, "service-task.xml");
60
+ return { backend: "task-scheduler", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
61
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
62
+ <Triggers><TimeTrigger><Repetition><Interval>PT${minutes}M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${new Date(now + 60000).toISOString()}</StartBoundary><Enabled>true</Enabled><RandomDelay>PT1M</RandomDelay></TimeTrigger></Triggers>
63
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
64
+ <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><StartWhenAvailable>true</StartWhenAvailable><Enabled>true</Enabled><WakeToRun>false</WakeToRun><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority></Settings>
65
+ <Actions Context="Author"><Exec><Command>${xml(executable)}</Command><Arguments>${xml(args.map(windowsQuote).join(" "))}</Arguments><WorkingDirectory>${xml(home)}</WorkingDirectory></Exec></Actions></Task>
66
+ `]], probe: ["schtasks.exe", ["/Query", "/TN", name, "/XML"]], install: [["schtasks.exe", ["/Create", "/TN", name, "/XML", file, "/F"]]], uninstall: [["schtasks.exe", ["/Delete", "/TN", name, "/F"]]] };
67
+ }
68
+ throw new Error("Automatic sync supports macOS, Windows, and Linux with a user systemd manager (including running WSL distributions).");
69
+ }
70
+
71
+ async function readJson(path) {
72
+ try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
73
+ }
74
+
75
+ async function atomicWrite(path, content) {
76
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
77
+ const temp = `${path}.${randomUUID()}.tmp`;
78
+ try {
79
+ await writeFile(temp, content, { mode: 0o600, flag: "wx" });
80
+ await rename(temp, path);
81
+ } finally { await unlink(temp).catch(() => {}); }
82
+ }
83
+
84
+ const runCommand = ([command, args]) => execute(command, args, { timeout: 15000, maxBuffer: 256 * 1024, windowsHide: true });
85
+
86
+ export async function manageService(action, { directory, cli, minutes, env = process.env, executeCommand = runCommand, home = homedir(), platform = process.platform }) {
87
+ directory = resolve(directory);
88
+ const settingsPath = join(directory, "service.json");
89
+ const settings = await readJson(settingsPath);
90
+ const configHome = settings?.configHome || env.XDG_CONFIG_HOME || join(home, ".config");
91
+ const plan = servicePlan({ directory, cli, executable: process.execPath, minutes: minutes ?? settings?.minutes ?? 15, configHome, home, platform });
92
+ if (action === "status") {
93
+ let registered = false;
94
+ try { await executeCommand(plan.probe); registered = true; } catch { /* Not installed, inactive, or unavailable. */ }
95
+ return { installed: Boolean(settings), schedulerReachable: registered, backend: plan.backend, minutes: settings?.minutes ?? null, lastRun: await readJson(join(directory, "service-state.json")), hint: registered ? "Check lastRun for sync health. Windows task registration does not prove it is enabled." : "Scheduler is not active/reachable. On WSL, start the distro and enable its systemd user manager." };
96
+ }
97
+ if (action === "uninstall") {
98
+ if (!settings) return { installed: false };
99
+ // Failure is surfaced: do not claim removal while a scheduler might still run.
100
+ for (const command of plan.uninstall) await executeCommand(command);
101
+ for (const [file] of plan.files) await unlink(file).catch((error) => { if (error.code !== "ENOENT") throw error; });
102
+ await unlink(settingsPath);
103
+ if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "daemon-reload"]]);
104
+ return { installed: false, retained: "Account link, usage checkpoints, and last-run status retained. An in-flight sync may finish." };
105
+ }
106
+ if (action !== "install") throw new Error("Use service install [--every 15], status, run, or uninstall.");
107
+ assertDurablePath(await realpath(cli));
108
+ assertDurablePath(await realpath(process.execPath));
109
+ await access(join(directory, "config.json"));
110
+ // Check the user manager before writing anything. WSL without systemd fails clearly.
111
+ if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "show-environment"]]);
112
+ if (settings) {
113
+ let registered = false;
114
+ try { await executeCommand(plan.probe); registered = true; } catch { /* Missing registration can be repaired. */ }
115
+ if (registered) for (const command of plan.uninstall) await executeCommand(command);
116
+ }
117
+ const next = { version: 1, minutes: intervalMinutes(minutes ?? settings?.minutes ?? 15), configHome, env: capturedEnvironment(env), installedAt: new Date().toISOString() };
118
+ await atomicWrite(settingsPath, JSON.stringify(next));
119
+ for (const [file, content] of plan.files) await atomicWrite(file, content);
120
+ try {
121
+ for (const command of plan.install) await executeCommand(command);
122
+ await executeCommand(plan.probe);
123
+ } catch (error) {
124
+ throw new Error("Scheduler registration failed. Settings were retained for repair; rerun service install or check service status.", { cause: error });
125
+ }
126
+ return { installed: true, backend: plan.backend, minutes: next.minutes, note: "Opt-in one-shot jobs; no daemon, auto-updater, or wake-from-sleep. Reinstall after moving/upgrading the CLI or changing source paths." };
127
+ }
128
+
129
+ export async function runScheduledSync(directory, action, { now = () => Date.now(), env = process.env } = {}) {
130
+ const settings = await readJson(join(directory, "service.json"));
131
+ if (!settings) return { status: "disabled" };
132
+ const path = join(directory, "service-state.json");
133
+ const previous = await readJson(path) || {};
134
+ if (previous.nextAttemptAt > now()) return { status: "backoff", nextAttemptAt: previous.nextAttemptAt };
135
+ Object.assign(env, capturedEnvironment(settings.env || {}));
136
+ const start = now();
137
+ let state;
138
+ try {
139
+ await action();
140
+ state = { status: "ok", failures: 0, lastSuccessAt: new Date(now()).toISOString() };
141
+ } catch (error) {
142
+ if (error.code === "USAGEMAX_BUSY") return { status: "busy" };
143
+ const failures = Math.min((previous.failures || 0) + 1, 10);
144
+ // Save no command output, tokens, paths, or raw server error bodies.
145
+ state = { status: "error", failures, lastSuccessAt: previous.lastSuccessAt ?? null, nextAttemptAt: now() + Math.min(6 * 60, settings.minutes * 2 ** failures) * 60000, hint: "Run usagemax sync manually to diagnose; checkpoints are preserved." };
146
+ }
147
+ state = { ...state, attemptedAt: new Date(start).toISOString(), durationMs: now() - start };
148
+ await atomicWrite(path, JSON.stringify(state));
149
+ return state;
150
+ }