usagemax 0.3.2 → 0.3.6

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
@@ -1,159 +1,218 @@
1
1
  # UsageMax CLI
2
2
 
3
- Connect aggregate coding-agent usage from every computer to one private UsageMax workspace.
3
+ Connect the AI usage history on a computer to one private UsageMax workspace.
4
+ The CLI is deliberately short-lived: it scans locally, uploads bounded
5
+ aggregates, and exits.
4
6
 
5
- ## Quick start
7
+ [UsageMax](https://usagemax.com) · [Account](https://usagemax.com/account) ·
8
+ [CLI documentation](https://usagemax.com/cli.md) ·
9
+ [API contract](https://usagemax.com/openapi.json) ·
10
+ [npm package](https://www.npmjs.com/package/usagemax) ·
11
+ [source repository](https://github.com/SYMBaiEX/usagemax/tree/main/packages/cli)
12
+
13
+ This package is the open-source `usagemax` command-line collector. It is not a
14
+ JavaScript or Python SDK and does not expose an import API. For programmatic
15
+ integrations, use the documented [OpenAPI contract](https://usagemax.com/openapi.json)
16
+ or the public [agent surfaces](https://usagemax.com/?mode=agent); the package
17
+ itself is intended to be invoked as a short-lived local process.
6
18
 
7
- 1. Sign in at [usagemax.com/account](https://usagemax.com/account).
8
- 2. Choose **Link a computer** and copy the one-time command.
9
- 3. Run it in each operating-system environment that contains usage history:
19
+ ## Requirements
20
+
21
+ - Node.js 20 or newer
22
+ - Bun or npm
23
+ - A UsageMax account and one link code per computer or WSL distribution
24
+
25
+ ## Quick start
10
26
 
11
27
  ```bash
28
+ # Create a one-use code at https://usagemax.com/account.
12
29
  bunx usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
30
+
31
+ # npm users can run the same one-shot command with npx.
32
+ npx --yes usagemax@latest link UMX-XXXX-XXXX-XXXX-XXXX
33
+
34
+ # Preview, then upload changed local usage.
35
+ bunx usagemax sync --dry-run --explain
36
+ bunx usagemax sync
13
37
  ```
14
38
 
15
- The link code expires after ten minutes and can be used once. Create a new code
16
- for each Mac, Windows PC, Linux computer, and WSL distribution. All linked
17
- collectors roll up into the same profile. When run inside WSL, UsageMax includes
18
- readable supported-provider homes under `/mnt/c/Users` automatically. Use the
19
- WSL collector as the single collector for that Windows PC instead of linking the
20
- same host history again from Windows.
39
+ Agent-friendly checks can request JSON and keep the secret out of arguments and
40
+ logs. This example only inspects local source coverage:
41
+
42
+ ```bash
43
+ set +x
44
+ bunx usagemax doctor --deep --json | jq '{complete, sources: [.sources[] | {name, status}]}'
45
+ ```
21
46
 
22
- The CLI stores the resulting collector key in a user-only config file, then runs
23
- a one-shot full-history sync. Running it again, changing the display name, or
24
- relinking an installation does not create a second device: the private random
25
- installation identity remains stable.
47
+ The JSON shape is intended for local automation; unsupported or unavailable
48
+ sources remain explicitly reported rather than being inferred.
49
+
50
+ The link code expires after ten minutes and is consumed once. The account-side
51
+ computer name is retained. Pass `--name "Work laptop"` only when the current
52
+ CLI should explicitly override it.
53
+
54
+ Each installation gets a stable random ID and a new write-only collector key.
55
+ Relinking or renaming that installation rotates the key without creating a
56
+ second device. Do not link two installations to the same copied or
57
+ network-mounted log tree; cross-installation duplicate history is ambiguous.
26
58
 
27
59
  ## Commands
28
60
 
61
+ ```text
62
+ usagemax Sync changed local usage
63
+ usagemax link <code> [options] Link and sync a computer
64
+ usagemax sync [options] Reconcile local usage once
65
+ usagemax status Show local link state
66
+ usagemax doctor Check discovered sources
67
+ usagemax report [ccusage args] Run a local ccusage report
68
+ usagemax token status Diagnose a key piped on stdin
69
+ usagemax service install Opt into periodic OS checkpoints
70
+ usagemax service status|run|uninstall
71
+ usagemax unlink [--revoke] Remove local credentials
72
+ ```
73
+
74
+ Useful options:
75
+
29
76
  ```bash
30
- bunx usagemax # sync changed usage
31
- bunx usagemax sync # same as above
32
- bunx usagemax sync --full # reconcile all retained local history
33
- bunx usagemax sync --archives # one-time compressed-history recovery
34
- bunx usagemax sync --dry-run --explain
35
- # inspect the exact bounded plan; upload nothing
36
- bunx usagemax link UMX-… --no-sync # link without uploading yet
37
- bunx usagemax status # show link and last-sync state
38
- bunx usagemax doctor # metadata-only source check
39
- bunx usagemax doctor --deep --json # machine-readable retained-history audit
40
- bunx usagemax report # open ccusage's local daily report
41
- bunx usagemax report session --breakdown
42
- bunx usagemax unlink # remove the local collector key
43
- bunx usagemax unlink --revoke # disable future uploads, then remove locally
77
+ bunx usagemax sync --full # all retained local history
78
+ bunx usagemax sync --archives # one-time compressed-history recovery
79
+ bunx usagemax sync --restart # restart an expired saved upload
80
+ bunx usagemax status --remote --json # remote check; secret is never printed
81
+ bunx usagemax doctor --deep --json # parse and audit retained history
82
+ bunx usagemax link UMX-… --no-sync # link without uploading yet
44
83
  ```
45
84
 
85
+ ## Sources and coverage
86
+
46
87
  UsageMax pins [ccusage v20.0.20](https://github.com/ccusage/ccusage/releases/tag/v20.0.20)
47
- and supports all 16 adapters shipped in that release: Amp, Claude Code, Codebuff,
48
- Codex, GitHub Copilot CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes,
49
- Kilo Code, Kimi CLI, OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format
50
- stores configured through ccusage are also discovered.
51
-
52
- The source inventory follows ccusage's environment overrides, including
53
- `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `OPENCODE_DATA_DIR`, `AMP_DATA_DIR`,
54
- `DROID_SESSIONS_DIR`, `CODEBUFF_DATA_DIR`, `HERMES_HOME`, `PI_AGENT_DIR`,
55
- `GOOSE_PATH_ROOT`, `OPENCLAW_DIR`, `KILO_DATA_DIR`, `KIMI_DATA_DIR`,
56
- `QWEN_DATA_DIR`, `COPILOT_OTEL_FILE_EXPORTER_PATH`, `GEMINI_DATA_DIR`, and
57
- `GROK_HOME`. It also follows XDG Claude configuration and Windows Goose storage.
58
-
59
- UsageMax also discovers Claude Desktop local-agent sessions, `.cc-mirror`,
60
- recognizable renamed Claude/Codex backup folders, and supported Windows homes
61
- from WSL. Discovery is bounded to known locations and immediate home entries;
62
- normal syncs do not crawl the whole disk.
63
- On a multi-user WSL host, automatic Windows-home discovery stays off unless
64
- there is exactly one provider-bearing profile; set `USAGEMAX_ADDITIONAL_HOME`
65
- to the intended mounted home explicitly.
66
-
67
- If `doctor` reports compressed provider archives, run `sync --archives` once.
68
- Recovery extracts only safe Claude `projects/*.jsonl` members into a private
69
- temporary directory, performs one full deduplicated reconciliation, and removes
70
- the temporary files before exit. Weekly and incremental syncs never unpack
71
- archives.
72
-
73
- Local files are only one coverage layer. Cursor, Windsurf, Aider, Continue,
74
- Cline, Roo Code, direct provider API traffic, hosted agents, and enterprise
75
- billing systems do not all expose a stable local token ledger. Capture those
76
- through UsageMax's native or OTLP endpoint, or through a future provider billing
77
- connector. UsageMax never invents usage that the source did not retain.
78
-
79
- ## Privacy, correctness, and load
80
-
81
- Interrupted uploads resume with `bunx usagemax sync`. Server upload runs expire
82
- after 30 idle days. If the server reports an expired run, use `sync --restart`:
83
- this preserves the last committed checkpoint and performs a fresh full scan.
84
- It never automatically replays an old authoritative deletion against newer data.
85
-
86
- - The sync payload contains aggregate token counts,
87
- model/provider names, costs, source names, dates, coverage state, and opaque
88
- SHA-256 session identities.
89
- - It does not upload prompts, completions, source code, file contents, project
90
- paths, or provider credentials.
91
- - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
92
- - A successful previous parse with a complete, unchanged metadata inventory skips
93
- parsing and uploading again that day. Inventory stability is independent of
94
- deletion authority; a no-change result retains the previous partial coverage
95
- label. New sources, date rollover, explicit full/archive requests and weekly
96
- reconciliation still trigger the appropriate scan.
97
- - Normal changed syncs parse today or today plus yesterday. A bounded weekly
98
- full reconciliation catches restored files, parser changes, and older logs.
99
- - Full history scans retained local history from 2024 onward. Inventory success
100
- does not prove every file parsed. Until the parser certifies source/day coverage,
101
- uploads are marked partial and any row with a decreasing counter retains its
102
- entire previous vector. This includes explicit zeros and missing sources.
103
- Older checkpoints survive incremental windows. Authoritative corrections remain
104
- supported by the planner but are not claimed by this parser integration.
105
- Deleted or never-persisted usage requires a provider export.
106
- - Source totals that cannot be assigned to a model are retained as
107
- `unattributed` rather than silently discarded.
108
- - The collector key is written with user-only permissions where the operating
109
- system supports them.
110
- - The package contains no shared service credential or private deployment URL.
111
- It talks to the versioned `https://usagemax.com/api` contract. The random
112
- per-installation write token is the only local secret; the service stores only
113
- its hash and applies device binding, replay checks, payload caps, and quotas.
114
- - A private random installation ID survives collector rotation, relinking, and
115
- display-name changes. It is not a hardware fingerprint; the server stores only
116
- its SHA-256 hash. A local config lock prevents overlapping commands from
117
- overwriting pending runs; server receipts make repeated uploads idempotent.
118
- - The server, not the local checkpoint, owns the accounting baseline. A lost
119
- response or interrupted run can be retried without adding the same partition twice.
120
- - Do not point two different installations at the same copied or network-mounted
121
- log tree. Cross-installation copied-history deduplication is inherently
122
- ambiguous and intentionally not guessed.
123
-
124
- Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development and
125
- self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
126
-
127
- ## Interrupted uploads and protocol 0.3.1
128
-
129
- Before uploading, the CLI saves the exact run, ordered request payloads and next
130
- checkpoint in the private config. `sync` resumes this journal before scanning new
131
- data. Local snapshots advance only after completion is acknowledged. A resumed
132
- command finishes the saved scan; run `sync` again to collect subsequent changes.
133
- `sync --dry-run` reports pending work without uploading. Do not delete config.json
134
- to retry a failed upload. A successful relink or unlink replaces/removes the local
135
- journal along with its credential.
136
-
137
- Network failures, malformed success responses, HTTP 429 and 5xx get at most five
138
- attempts per request with exponential jitter. Retry-After seconds and HTTP dates
139
- are honored up to 60 seconds; longer waits stop with an instruction to retry later.
140
- Only completion's `snapshot_run_incomplete` HTTP 409 is retried, because server
141
- cleanup can still be pending. Authentication, validation and other conflicts fail
142
- with an actionable message and retain the journal. JSON `accepted` is null for
143
- uploads because lost responses/replays cannot reliably reconstruct that count;
144
- `changedRows` is the local planned count, not a server accounting receipt.
145
-
146
- Each sorted source/day is sent as ordered chunks of at most 100 rows. Each has a
147
- unique partitionId, zero-based chunkIndex and shared chunkCount. The payload hash
148
- covers `{source, day, complete, pricingVersion, chunkIndex, chunkCount, rows}` in
149
- that order. `partitionCount` counts transmitted chunks. The server must accept
150
- this protocol, receipt replays, and finish omitted-row cleanup before completing
151
- an authoritative run.
152
-
153
- An abrupt process kill can leave `collector.lock` in the config directory. The
154
- next command reports the owner PID and exact path. Confirm that process has exited
155
- before removing only that lock file, then rerun sync. Never remove an active lock
156
- or the saved config to recover. Normal completion and handled failures release it.
157
-
158
- See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
159
- the full support matrix and known boundaries.
88
+ and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
89
+ CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
90
+ OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
91
+ well.
92
+
93
+ The collector follows the supported provider environment overrides and bounded
94
+ home locations. It recognizes Claude Desktop sessions, `.cc-mirror`, renamed
95
+ Claude/Codex backup folders, and supported Windows homes from WSL. In WSL, use
96
+ one collector for the Windows provider homes it can read instead of linking the
97
+ same history again from Windows.
98
+
99
+ Full scans catalog retained history from 2024 onward. `sync --archives` safely
100
+ extracts supported Claude JSONL members into a private temporary directory and
101
+ removes them after reconciliation. Normal runs do not crawl the whole disk or
102
+ unpack archives.
103
+
104
+ Cursor, Windsurf, Aider, Continue, Cline, Roo Code, hosted agents, and direct
105
+ provider API traffic may not leave a stable local token ledger. Use UsageMax's
106
+ native or OTLP/HTTP JSON contract, or a provider billing export, when local
107
+ evidence is unavailable. Unsupported usage is never guessed.
108
+
109
+ ## Safe collector diagnostics
110
+
111
+ An advanced key from **Advanced · custom telemetry collector** is a
112
+ `umx_` prefix followed by 64 lowercase hexadecimal characters. Pipe it through
113
+ stdin; never pass it as an argument or put it in a URL:
114
+
115
+ ```bash
116
+ set +x
117
+ printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
118
+ | bunx usagemax@latest token status \
119
+ --device-id "$USAGEMAX_INSTALLATION_ID" \
120
+ --json
121
+ ```
122
+
123
+ The `token status` command is included in CLI `0.3.6`. If the public npm tag
124
+ does not yet contain `0.3.6`, run `node packages/cli/src/cli.js token status`
125
+ from the UsageMax repository until that release is published.
126
+
127
+ The response is read-only and contains only status, type, scopes, profile/name,
128
+ activation state, and a binding result of `unbound`, `bound`, `matched`, or
129
+ `mismatch`. It never returns the token, hash, or raw authorized UUID.
130
+
131
+ For a numeric-only result suitable for a smoke check:
132
+
133
+ ```bash
134
+ set +x
135
+ printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
136
+ | bunx usagemax@latest token status \
137
+ --device-id "$USAGEMAX_INSTALLATION_ID" --json \
138
+ | jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
139
+ ```
140
+
141
+ `200 1` is active and ingestion-authorized. `200 0` is recognized but blocked;
142
+ inspect `status` and `scopeStatus` in the unfiltered JSON. `409 0` is a device
143
+ binding mismatch. `401 0` means the format/key was rejected.
144
+
145
+ New advanced keys are active immediately and need no activation or propagation.
146
+ They bind on their first valid write. Linked CLI keys are bound during the link
147
+ exchange. A `401` means the key format is invalid or the key is unknown,
148
+ revoked, disabled, or from another deployment. A `409` means the supplied
149
+ installation does not match. A recognized key missing `telemetry:write` has
150
+ `status: scope_missing`, `scopeStatus: missing_telemetry_write`, and
151
+ `ingestAuthorized: false`.
152
+
153
+ For a write-path smoke check, the root README includes a `curl` request that
154
+ sends one `agent_state` event with all token counters and `costMicros` set to
155
+ zero. It is observability-only: `agent_state` does not update accounting, and
156
+ the probe may bind an otherwise unbound advanced key.
157
+
158
+ ## Privacy and resource use
159
+
160
+ The CLI uploads aggregate token counters, provider/model names, source names,
161
+ dates, cost provenance, coverage state, and opaque SHA-256 session identities.
162
+ It never uploads prompts, completions, source code, file contents, project
163
+ paths, tool payloads, or provider credentials.
164
+
165
+ Every sync is one-shot. A complete unchanged inventory can skip parsing and
166
+ uploading; date rollover, new sources, a weekly reconciliation, `--full`, or
167
+ `--archives` triggers the appropriate bounded scan. Decreases and deletions are
168
+ protected while coverage is incomplete.
169
+
170
+ Optional scheduling invokes the same process at low priority:
171
+
172
+ ```bash
173
+ bun install -g usagemax
174
+ usagemax service install # approximately every 15 minutes
175
+ usagemax service install --every 30 # 5–1440 minutes
176
+ usagemax service status
177
+ usagemax service uninstall
178
+ ```
179
+
180
+ The scheduler uses a user LaunchAgent on macOS, a user systemd timer on
181
+ Linux/WSL, and Task Scheduler on Windows. It does not install a resident
182
+ watcher, wake a sleeping computer, download packages per run, or replay missed
183
+ intervals. Failures back off for up to six hours and the collector lock prevents
184
+ overlapping syncs.
185
+
186
+ ## Recovery and checkpoints
187
+
188
+ Uploads are idempotent and resumable. Before network I/O, the CLI saves a
189
+ bounded journal containing the current run, ordered operations, and next
190
+ checkpoint in the user-only config directory. If a process or network request
191
+ fails, rerun:
192
+
193
+ ```bash
194
+ usagemax sync
195
+ ```
196
+
197
+ An expired run can be restarted with `sync --restart`; already accepted usage
198
+ remains safe. Do not delete `config.json` to retry a failed upload. See the
199
+ [operations runbook](https://github.com/SYMBaiEX/usagemax/blob/main/docs/operations-runbook.md)
200
+ for recovery guidance.
201
+
202
+ ## Development
203
+
204
+ From the repository root:
205
+
206
+ ```bash
207
+ bun install
208
+ bun run --cwd packages/cli test
209
+ bun run --cwd packages/cli pack:check # dry-run; lifecycle scripts disabled
210
+ ```
211
+
212
+ `pack:check` only inspects the local archive shape. It does not publish, contact
213
+ the npm registry, or establish that any registry tag contains this version.
214
+
215
+ The package is MIT-licensed. See the included [LICENSE](LICENSE), the
216
+ repository [LICENSE](https://github.com/SYMBaiEX/usagemax/blob/main/LICENSE),
217
+ [security policy](https://github.com/SYMBaiEX/usagemax/blob/main/SECURITY.md),
218
+ and [contributing guide](https://github.com/SYMBaiEX/usagemax/blob/main/CONTRIBUTING.md).
package/package.json CHANGED
@@ -1,7 +1,21 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.3.2",
3
+ "version": "0.3.6",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
+ "keywords": [
6
+ "usagemax",
7
+ "ai-usage",
8
+ "usage-analytics",
9
+ "coding-agents",
10
+ "agent-usage",
11
+ "telemetry",
12
+ "observability",
13
+ "usage",
14
+ "ccusage",
15
+ "claude-code",
16
+ "codex",
17
+ "privacy"
18
+ ],
5
19
  "license": "MIT",
6
20
  "author": "UsageMax",
7
21
  "homepage": "https://usagemax.com",
@@ -25,6 +39,7 @@
25
39
  "src/sources.js",
26
40
  "src/transport.js",
27
41
  "src/resume.js",
42
+ "src/service.js",
28
43
  "README.md",
29
44
  "LICENSE"
30
45
  ],
@@ -36,7 +51,7 @@
36
51
  },
37
52
  "scripts": {
38
53
  "test": "node --test src/*.test.js",
39
- "pack:check": "npm pack --dry-run",
54
+ "pack:check": "npm pack --dry-run --ignore-scripts",
40
55
  "prepublishOnly": "npm test"
41
56
  },
42
57
  "dependencies": {
package/src/cli.js CHANGED
@@ -8,21 +8,30 @@ 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";
15
- import { requestSnapshot } from "./transport.js";
16
+ import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
17
+ import { collectorStatusView, requestCollectorStatus, 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
 
21
+ // Make the short-lived collector recognizable in Activity Monitor and `ps`.
22
+ // Windows may still display the underlying node.exe image name in Task Manager.
23
+ process.title = "UsageMax";
24
+
19
25
  const require = createRequire(import.meta.url);
20
26
  const executeFile = promisify(execFile);
21
- const VERSION = "0.3.2";
27
+ const VERSION = "0.3.6";
22
28
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
23
29
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
30
+ const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
24
31
  const CONFIG_FILE = "config.json";
25
32
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
33
+ const TOKEN_PATTERN = /^umx_[a-f0-9]{64}$/;
34
+ const DEVICE_PATTERN = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
26
35
 
27
36
  function configDirectory() {
28
37
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -49,18 +58,21 @@ function isLegacyDirectApi(config) {
49
58
  async function readConfig() {
50
59
  try {
51
60
  const parsed = JSON.parse(await readFile(configPath(), "utf8"));
52
- if (!parsed || parsed.version !== 1 || !/^umx_[a-f0-9]{64}$/.test(parsed.token || "")) return null;
61
+ if (!parsed || parsed.version !== 1 || !TOKEN_PATTERN.test(parsed.token || "")) return null;
53
62
  if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
54
63
  if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
55
64
  parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
56
65
  if (isLegacyDirectApi(parsed)) {
57
66
  parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
58
67
  parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
68
+ parsed.statusUrl = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
59
69
  parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
60
70
  await writeConfig(parsed);
61
71
  }
62
72
  parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
63
73
  || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
74
+ parsed.statusUrl = validHttpsUrl(parsed.statusUrl, { allowLocalhost: true })
75
+ || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
64
76
  parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
65
77
  || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
66
78
  return parsed;
@@ -107,6 +119,12 @@ function help() {
107
119
  process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
108
120
  process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
109
121
  process.stdout.write(" usagemax status Show local link status\n");
122
+ process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
123
+ process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
124
+ process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
125
+ process.stdout.write(" usagemax service install [--every 15]\n");
126
+ process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
127
+ process.stdout.write(" usagemax service status|run|uninstall\n");
110
128
  process.stdout.write(" usagemax doctor [--deep] [--json]\n");
111
129
  process.stdout.write(" Check source coverage; --deep parses full history\n");
112
130
  process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
@@ -127,6 +145,8 @@ async function ccusageJson(config, { full = false, env } = {}) {
127
145
  const { stdout } = await executeFile(process.execPath, args, {
128
146
  encoding: "utf8",
129
147
  maxBuffer: MAX_REPORT_BYTES,
148
+ timeout: 10 * 60 * 1000,
149
+ killSignal: "SIGKILL",
130
150
  env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
131
151
  });
132
152
  return JSON.parse(stdout);
@@ -160,7 +180,8 @@ async function link(args) {
160
180
  const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
161
181
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
162
182
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
163
- const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
183
+ const requestedName = option(args, "--name");
184
+ const name = requestedName?.trim().slice(0, 80) || undefined;
164
185
  const previous = await readConfig();
165
186
  const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
166
187
  const headers = { "content-type": "application/json" };
@@ -168,33 +189,36 @@ async function link(args) {
168
189
  const response = await fetch(endpoint, {
169
190
  method: "POST",
170
191
  headers,
171
- body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
192
+ body: JSON.stringify({ code, ...(name ? { name, nameExplicit: true } : {}), platform: platform(), cliVersion: VERSION, deviceId }),
172
193
  signal: AbortSignal.timeout(15_000),
173
194
  });
174
195
  const body = await response.json().catch(() => ({}));
175
196
  if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
176
197
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
177
198
  const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
199
+ const statusUrl = validHttpsUrl(body.statusUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
178
200
  const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
179
- if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl || !snapshotUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
201
+ if (!TOKEN_PATTERN.test(body.token || "") || !ingestUrl || !snapshotUrl || !statusUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
180
202
  const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
203
+ const savedName = typeof body.deviceName === "string" && body.deviceName.trim() ? body.deviceName.trim().slice(0, 80) : (name || deviceLabel());
181
204
  const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
182
205
  const config = {
183
206
  version: 1,
184
207
  token: body.token,
185
208
  ingestUrl,
186
209
  snapshotUrl,
210
+ statusUrl,
187
211
  revokeUrl,
188
212
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
189
213
  profileHandle,
190
214
  deviceId,
191
- deviceName: name,
215
+ deviceName: savedName,
192
216
  linkedAt: new Date().toISOString(),
193
217
  snapshots: sameAccount ? previous.snapshots : {},
194
218
  };
195
219
  await writeConfig(config);
196
220
  warnVersion(body);
197
- process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
221
+ process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
198
222
  if (args.includes("--no-sync")) {
199
223
  process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
200
224
  return;
@@ -345,9 +369,37 @@ async function syncPrepared(args, suppliedConfig, recovery) {
345
369
  return result;
346
370
  }
347
371
 
348
- async function status() {
372
+ function collectorStatusEndpoint(config) {
373
+ return validHttpsUrl(process.env.USAGEMAX_STATUS_ENDPOINT, { allowLocalhost: true })
374
+ || validHttpsUrl(config.statusUrl, { allowLocalhost: true })
375
+ || config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
376
+ }
377
+
378
+ function printRemoteStatus(view) {
379
+ process.stdout.write(`Remote credential: ${view.status || "unavailable"}${view.httpStatus ? ` (HTTP ${view.httpStatus})` : ""}\n`);
380
+ if (view.reason) process.stdout.write(`${view.reason}\n`);
381
+ if (view.credentialType) process.stdout.write(`Type: ${view.credentialType}${view.writeOnly ? "; write-only" : ""}\n`);
382
+ if (view.scopes?.length) process.stdout.write(`Scopes: ${view.scopes.join(", ")}\n`);
383
+ if (view.scopeStatus) process.stdout.write(`Ingest scope: ${view.scopeStatus === "valid" ? "authorized" : "missing telemetry:write"}\n`);
384
+ if (view.deviceBinding) process.stdout.write(`Device binding: ${view.deviceBinding}\n`);
385
+ if (view.profileHandle) process.stdout.write(`Profile: @${view.profileHandle}\n`);
386
+ if (view.deviceName) process.stdout.write(`Computer: ${view.deviceName}\n`);
387
+ if (view.platform || view.cliVersion) process.stdout.write(`Runtime: ${view.platform || "unknown"}${view.cliVersion ? ` · CLI ${view.cliVersion}` : ""}\n`);
388
+ if (view.activation) process.stdout.write(`Activation: ${view.activation}\n`);
389
+ if (view.expiresAt === null) process.stdout.write("Expiration: none\n");
390
+ if (view.lastSuccessAt) process.stdout.write(`Last accepted write: ${new Date(view.lastSuccessAt).toISOString()}\n`);
391
+ if (view.lastFailureAt) process.stdout.write(`Last rejected write: ${new Date(view.lastFailureAt).toISOString()}${view.lastFailureCode ? ` · ${view.lastFailureCode}` : ""}\n`);
392
+ }
393
+
394
+ async function status(args = []) {
349
395
  const config = await readConfig();
396
+ const remote = args.includes("--remote");
397
+ const json = args.includes("--json");
350
398
  if (!config) {
399
+ if (json) {
400
+ process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
401
+ return;
402
+ }
351
403
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
352
404
  return;
353
405
  }
@@ -356,11 +408,70 @@ async function status() {
356
408
  config.deviceId = stableId;
357
409
  await writeConfig(config);
358
410
  }
411
+ const local = {
412
+ linked: true,
413
+ deviceName: config.deviceName || deviceLabel(),
414
+ profileHandle: config.profileHandle || null,
415
+ deviceIdConfigured: Boolean(config.deviceId),
416
+ lastSyncAt: config.lastSyncAt || null,
417
+ lastFullSyncAt: config.lastFullSyncAt || null,
418
+ pendingSync: config.pendingSync?.runId || null,
419
+ profileUrl: config.profileUrl || "https://usagemax.com/account",
420
+ };
421
+ let remoteView;
422
+ if (remote) {
423
+ try {
424
+ const result = await requestCollectorStatus(collectorStatusEndpoint(config), config);
425
+ remoteView = collectorStatusView(result.httpStatus, result.body, config.token);
426
+ } catch (error) {
427
+ remoteView = { tokenFormat: "valid", status: "unavailable", reason: error instanceof Error ? error.message : "Collector status unavailable." };
428
+ }
429
+ if (json) {
430
+ process.stdout.write(`${JSON.stringify({ ...local, remote: remoteView })}\n`);
431
+ return;
432
+ }
433
+ }
434
+ if (json) {
435
+ process.stdout.write(`${JSON.stringify(local)}\n`);
436
+ return;
437
+ }
359
438
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
360
439
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
361
440
  process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
362
441
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
363
442
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
443
+ if (remote) printRemoteStatus(remoteView);
444
+ }
445
+
446
+ async function readTokenFromStdin() {
447
+ if (process.stdin.isTTY) throw new Error("Pipe the collector token on stdin; never pass it as a command-line argument.");
448
+ // fs.promises.readFile does not consistently accept file descriptor 0
449
+ // across the Node versions supported by the CLI. Read the pipe as a stream
450
+ // instead, and bound it so an accidental large stdin cannot be buffered.
451
+ let input = "";
452
+ for await (const chunk of process.stdin) {
453
+ input += String(chunk);
454
+ if (input.length > 256) throw new Error("stdin did not contain a valid UsageMax collector token.");
455
+ }
456
+ const token = input.trim();
457
+ if (!TOKEN_PATTERN.test(token)) throw new Error("stdin did not contain a valid UsageMax collector token (expected umx_ plus 64 lowercase hexadecimal characters).");
458
+ return token;
459
+ }
460
+
461
+ async function tokenStatus(args = []) {
462
+ const requestedDeviceId = option(args, "--device-id");
463
+ if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
464
+ const token = await readTokenFromStdin();
465
+ const configuredEndpoint = process.env.USAGEMAX_STATUS_ENDPOINT || DEFAULT_STATUS_ENDPOINT;
466
+ const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
467
+ if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
468
+ const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
469
+ const view = collectorStatusView(result.httpStatus, result.body, token);
470
+ if (args.includes("--json")) process.stdout.write(`${JSON.stringify(view)}\n`);
471
+ else {
472
+ process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
473
+ printRemoteStatus(view);
474
+ }
364
475
  }
365
476
 
366
477
  async function doctor(args = []) {
@@ -429,8 +540,10 @@ async function removeLink(args = []) {
429
540
  if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
430
541
  const response = await fetch(endpoint, {
431
542
  method: "POST",
432
- headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
433
- body: JSON.stringify({ deviceId: config.deviceId }),
543
+ headers: {
544
+ authorization: `Bearer ${config.token}`,
545
+ "x-usagemax-device-id": config.deviceId,
546
+ },
434
547
  signal: AbortSignal.timeout(15_000),
435
548
  });
436
549
  if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
@@ -446,12 +559,33 @@ async function main() {
446
559
  const command = args[0] || "sync";
447
560
  if (["--help", "-h", "help"].includes(command)) return help();
448
561
  if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
562
+ if (command === "service") {
563
+ const action = args[1] || "status";
564
+ const directory = option(args, "--config-dir") || configDirectory();
565
+ process.env.USAGEMAX_CONFIG_DIR = directory;
566
+ let result;
567
+ if (action === "run") {
568
+ result = await runScheduledSync(directory, () => withConfigLock(directory, () => sync(["--json"])));
569
+ if (result.status === "error") process.exitCode = 1;
570
+ } else if (action === "status") {
571
+ result = await manageService(action, { directory, cli: fileURLToPath(import.meta.url) });
572
+ } else {
573
+ result = await withConfigLock(directory, async () => {
574
+ if (action === "install" && !await readConfig()) throw new Error("Link this computer before enabling automatic sync.");
575
+ if (args.includes("--every") && option(args, "--every") === undefined) throw new Error("--every requires a number of minutes.");
576
+ return manageService(action, { directory, cli: fileURLToPath(import.meta.url), minutes: args.includes("--every") ? intervalMinutes(option(args, "--every")) : undefined });
577
+ });
578
+ }
579
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
580
+ return;
581
+ }
449
582
  if (command === "report") return report(args.slice(1));
583
+ if (command === "token" && args[1] === "status") return tokenStatus(args.slice(2));
450
584
  if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
451
585
  return withConfigLock(configDirectory(), async () => {
452
586
  if (command === "link") return link(args.slice(1));
453
587
  if (command === "sync") return sync(args.slice(1));
454
- if (command === "status") return status();
588
+ if (command === "status") return status(args.slice(1));
455
589
  if (command === "doctor") return doctor(args.slice(1));
456
590
  return removeLink(args.slice(1));
457
591
  });
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,212 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { access, chmod, copyFile, mkdir, readFile, readdir, 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 brandedRuntimePath(directory, platform = process.platform) {
31
+ if (platform !== "darwin" && platform !== "win32") return null;
32
+ return platform === "win32"
33
+ ? join(directory, "runtime", "UsageMax.exe")
34
+ : join(directory, "runtime", "bin", "UsageMax");
35
+ }
36
+
37
+ /**
38
+ * Give scheduled jobs a stable product-facing image name on platforms where
39
+ * the JavaScript runtime otherwise appears as `node` in process browsers.
40
+ * The copied runtime is intentionally private to this installation and is
41
+ * refreshed on every service install, so upgrading Node or UsageMax never
42
+ * leaves the scheduler pointing at an ephemeral cache path.
43
+ */
44
+ export async function ensureBrandedRuntime(directory, executable, platform = process.platform) {
45
+ const target = brandedRuntimePath(directory, platform);
46
+ if (!target) return executable;
47
+ const source = await realpath(executable);
48
+ const runtimeBinDirectory = dirname(target);
49
+ if (source === target) return target;
50
+ await mkdir(runtimeBinDirectory, { recursive: true, mode: 0o700 });
51
+ const temporary = join(runtimeBinDirectory, `.${platform === "win32" ? "UsageMax.exe" : "UsageMax"}.${randomUUID()}.tmp`);
52
+ try {
53
+ await copyFile(source, temporary);
54
+ if (platform !== "win32") await chmod(temporary, 0o700);
55
+ await rename(temporary, target);
56
+ if (platform === "darwin") {
57
+ // Homebrew Node uses @rpath/libnode.<n>.dylib next to its bin folder;
58
+ // the official Node distribution is self-contained. Copy only adjacent
59
+ // dylibs when they exist, keeping both layouts runnable.
60
+ const sourceLibraryDirectory = resolve(dirname(source), "../lib");
61
+ const runtimeLibraryDirectory = resolve(runtimeBinDirectory, "../lib");
62
+ const sourceLibraries = await readdir(sourceLibraryDirectory, { withFileTypes: true }).catch((error) => {
63
+ if (error?.code === "ENOENT") return [];
64
+ throw error;
65
+ });
66
+ const libraries = sourceLibraries.filter((entry) => entry.isFile() && entry.name.endsWith(".dylib"));
67
+ if (libraries.length) await mkdir(runtimeLibraryDirectory, { recursive: true, mode: 0o700 });
68
+ for (const library of libraries) {
69
+ const libraryTemporary = join(runtimeLibraryDirectory, `.${library.name}.${randomUUID()}.tmp`);
70
+ try {
71
+ await copyFile(join(sourceLibraryDirectory, library.name), libraryTemporary);
72
+ await chmod(libraryTemporary, 0o700);
73
+ await rename(libraryTemporary, join(runtimeLibraryDirectory, library.name));
74
+ } finally {
75
+ await unlink(libraryTemporary).catch(() => undefined);
76
+ }
77
+ }
78
+ }
79
+ } finally {
80
+ await unlink(temporary).catch(() => undefined);
81
+ }
82
+ return target;
83
+ }
84
+
85
+ export function servicePlan({ directory, executable, cli, minutes = 15, platform = process.platform, home = homedir(), uid = process.getuid?.(), configHome = join(home, ".config"), now = Date.now() }) {
86
+ minutes = intervalMinutes(minutes);
87
+ for (const path of [directory, executable, cli, home, configHome]) if (/[\x00-\x1f]/.test(path)) throw new Error("Scheduler paths cannot contain control characters.");
88
+ const id = createHash("sha256").update(directory).digest("hex").slice(0, 12);
89
+ const name = `com.UsageMax.sync.${id}`;
90
+ const args = [cli, "service", "run", "--config-dir", directory];
91
+ // Linux can execute the published shebang entry point directly. macOS and
92
+ // Windows launch the staged UsageMax runtime so process browsers do not show
93
+ // the generic JavaScript runtime image name.
94
+ const command = platform === "win32" || platform === "darwin"
95
+ ? [executable, ...args]
96
+ : [cli, "service", "run", "--config-dir", directory];
97
+ if (platform === "darwin") {
98
+ const file = join(home, "Library", "LaunchAgents", `${name}.plist`);
99
+ const domain = `gui/${uid}`;
100
+ return { backend: "launchd", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
101
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
102
+ <plist version="1.0"><dict><key>Label</key><string>${name}</string>
103
+ <key>ProgramArguments</key><array>${command.map((s) => `<string>${xml(s)}</string>`).join("")}</array>
104
+ <key>WorkingDirectory</key><string>${xml(home)}</string>
105
+ <key>StartInterval</key><integer>${minutes * 60 + parseInt(id.slice(0, 4), 16) % 60}</integer>
106
+ <key>ProcessType</key><string>Background</string><key>Nice</key><integer>10</integer>
107
+ <key>LowPriorityIO</key><true/><key>StandardOutPath</key><string>/dev/null</string>
108
+ <key>StandardErrorPath</key><string>/dev/null</string></dict></plist>
109
+ `]], probe: ["launchctl", ["print", `${domain}/${name}`]], install: [["launchctl", ["bootstrap", domain, file]], ["launchctl", ["enable", `${domain}/${name}`]]], uninstall: [["launchctl", ["bootout", `${domain}/${name}`]]] };
110
+ }
111
+ if (platform === "linux") {
112
+ const root = join(configHome, "systemd", "user");
113
+ return { backend: "systemd", name, files: [
114
+ [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`],
115
+ [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`],
116
+ ], 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`]]] };
117
+ }
118
+ if (platform === "win32") {
119
+ const file = join(directory, "service-task.xml");
120
+ return { backend: "task-scheduler", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
121
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
122
+ <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>
123
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
124
+ <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>
125
+ <Actions Context="Author"><Exec><Command>${xml(executable)}</Command><Arguments>${xml(args.map(windowsQuote).join(" "))}</Arguments><WorkingDirectory>${xml(home)}</WorkingDirectory></Exec></Actions></Task>
126
+ `]], probe: ["schtasks.exe", ["/Query", "/TN", name, "/XML"]], install: [["schtasks.exe", ["/Create", "/TN", name, "/XML", file, "/F"]]], uninstall: [["schtasks.exe", ["/Delete", "/TN", name, "/F"]]] };
127
+ }
128
+ throw new Error("Automatic sync supports macOS, Windows, and Linux with a user systemd manager (including running WSL distributions).");
129
+ }
130
+
131
+ async function readJson(path) {
132
+ try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
133
+ }
134
+
135
+ async function atomicWrite(path, content) {
136
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
137
+ const temp = `${path}.${randomUUID()}.tmp`;
138
+ try {
139
+ await writeFile(temp, content, { mode: 0o600, flag: "wx" });
140
+ await rename(temp, path);
141
+ } finally { await unlink(temp).catch(() => {}); }
142
+ }
143
+
144
+ const runCommand = ([command, args]) => execute(command, args, { timeout: 15000, maxBuffer: 256 * 1024, windowsHide: true });
145
+
146
+ export async function manageService(action, { directory, cli, minutes, env = process.env, executeCommand = runCommand, home = homedir(), platform = process.platform }) {
147
+ directory = resolve(directory);
148
+ const settingsPath = join(directory, "service.json");
149
+ const settings = await readJson(settingsPath);
150
+ const configHome = settings?.configHome || env.XDG_CONFIG_HOME || join(home, ".config");
151
+ const executable = brandedRuntimePath(directory, platform) || process.execPath;
152
+ const plan = servicePlan({ directory, cli, executable, minutes: minutes ?? settings?.minutes ?? 15, configHome, home, platform });
153
+ if (action === "status") {
154
+ let registered = false;
155
+ try { await executeCommand(plan.probe); registered = true; } catch { /* Not installed, inactive, or unavailable. */ }
156
+ 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." };
157
+ }
158
+ if (action === "uninstall") {
159
+ if (!settings) return { installed: false };
160
+ // Failure is surfaced: do not claim removal while a scheduler might still run.
161
+ for (const command of plan.uninstall) await executeCommand(command);
162
+ for (const [file] of plan.files) await unlink(file).catch((error) => { if (error.code !== "ENOENT") throw error; });
163
+ await unlink(settingsPath);
164
+ if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "daemon-reload"]]);
165
+ return { installed: false, retained: "Account link, usage checkpoints, and last-run status retained. An in-flight sync may finish." };
166
+ }
167
+ if (action !== "install") throw new Error("Use service install [--every 15], status, run, or uninstall.");
168
+ assertDurablePath(await realpath(cli));
169
+ if (platform === "darwin" || platform === "win32") assertDurablePath(await realpath(process.execPath));
170
+ await access(join(directory, "config.json"));
171
+ if (platform === "darwin" || platform === "win32") await ensureBrandedRuntime(directory, process.execPath, platform);
172
+ // Check the user manager before writing anything. WSL without systemd fails clearly.
173
+ if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "show-environment"]]);
174
+ if (settings) {
175
+ let registered = false;
176
+ try { await executeCommand(plan.probe); registered = true; } catch { /* Missing registration can be repaired. */ }
177
+ if (registered) for (const command of plan.uninstall) await executeCommand(command);
178
+ }
179
+ const next = { version: 1, minutes: intervalMinutes(minutes ?? settings?.minutes ?? 15), configHome, env: capturedEnvironment(env), installedAt: new Date().toISOString() };
180
+ await atomicWrite(settingsPath, JSON.stringify(next));
181
+ for (const [file, content] of plan.files) await atomicWrite(file, content);
182
+ try {
183
+ for (const command of plan.install) await executeCommand(command);
184
+ await executeCommand(plan.probe);
185
+ } catch (error) {
186
+ throw new Error("Scheduler registration failed. Settings were retained for repair; rerun service install or check service status.", { cause: error });
187
+ }
188
+ 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." };
189
+ }
190
+
191
+ export async function runScheduledSync(directory, action, { now = () => Date.now(), env = process.env } = {}) {
192
+ const settings = await readJson(join(directory, "service.json"));
193
+ if (!settings) return { status: "disabled" };
194
+ const path = join(directory, "service-state.json");
195
+ const previous = await readJson(path) || {};
196
+ if (previous.nextAttemptAt > now()) return { status: "backoff", nextAttemptAt: previous.nextAttemptAt };
197
+ Object.assign(env, capturedEnvironment(settings.env || {}));
198
+ const start = now();
199
+ let state;
200
+ try {
201
+ await action();
202
+ state = { status: "ok", failures: 0, lastSuccessAt: new Date(now()).toISOString() };
203
+ } catch (error) {
204
+ if (error.code === "USAGEMAX_BUSY") return { status: "busy" };
205
+ const failures = Math.min((previous.failures || 0) + 1, 10);
206
+ // Save no command output, tokens, paths, or raw server error bodies.
207
+ 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." };
208
+ }
209
+ state = { ...state, attemptedAt: new Date(start).toISOString(), durationMs: now() - start };
210
+ await atomicWrite(path, JSON.stringify(state));
211
+ return state;
212
+ }
package/src/transport.js CHANGED
@@ -7,6 +7,82 @@ export function retryAfterMs(value, now = Date.now()) {
7
7
  return Number.isFinite(date) ? Math.max(0, date - now) : 0;
8
8
  }
9
9
 
10
+ function record(value) {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
12
+ }
13
+
14
+ const collectorStates = new Set(["active", "revoked", "workspace_disabled", "membership_inactive", "device_mismatch", "scope_missing"]);
15
+ const deviceBindings = new Set(["unbound", "bound", "matched", "mismatch"]);
16
+ const collectorTokenPattern = /umx_[a-f0-9]{64}/gi;
17
+
18
+ function safeText(value, secret) {
19
+ let text = value;
20
+ if (secret) text = text.split(secret).join("[redacted]");
21
+ return text.replace(collectorTokenPattern, "[redacted]").slice(0, 160);
22
+ }
23
+
24
+ // Only copy the documented diagnostic projection. This keeps a compromised or
25
+ // misconfigured endpoint from echoing a collector secret through the CLI.
26
+ export function collectorStatusView(httpStatus, value, secret) {
27
+ const body = record(value);
28
+ const view = { tokenFormat: "valid", httpStatus };
29
+ if (!body) {
30
+ return {
31
+ ...view,
32
+ status: httpStatus === 401 ? "rejected" : "unavailable",
33
+ reason: httpStatus === 401
34
+ ? "UsageMax did not accept this collector token. It may be unknown, revoked, disabled, or from another deployment."
35
+ : "UsageMax returned no machine-readable collector status.",
36
+ };
37
+ }
38
+
39
+ if (typeof body.status === "string" && collectorStates.has(body.status)) view.status = body.status;
40
+ if (body.credentialType === "collector") view.credentialType = body.credentialType;
41
+ if (body.writeOnly === true) view.writeOnly = true;
42
+ if (body.activation === "not_required") view.activation = body.activation;
43
+ if (body.expiresAt === null) view.expiresAt = null;
44
+ if (Array.isArray(body.scopes)) view.scopes = body.scopes.filter((scope) => typeof scope === "string").slice(0, 16);
45
+ if (body.scopeStatus === "valid" || body.scopeStatus === "missing_telemetry_write") view.scopeStatus = body.scopeStatus;
46
+ if (typeof body.ingestAuthorized === "boolean") view.ingestAuthorized = body.ingestAuthorized;
47
+ if (typeof body.deviceBinding === "string" && deviceBindings.has(body.deviceBinding)) view.deviceBinding = body.deviceBinding;
48
+ for (const key of ["profileHandle", "deviceName", "platform", "cliVersion", "lastFailureCode"]) {
49
+ if (typeof body[key] === "string") view[key] = safeText(body[key], secret);
50
+ }
51
+ for (const key of ["createdAt", "lastSeenAt", "lastSuccessAt", "lastFailureAt"]) {
52
+ if (typeof body[key] === "number" && Number.isSafeInteger(body[key])) view[key] = body[key];
53
+ else if (body[key] === null) view[key] = null;
54
+ }
55
+ if (!view.status) {
56
+ view.status = httpStatus === 401 ? "rejected" : "unavailable";
57
+ view.reason = httpStatus === 401
58
+ ? "UsageMax did not accept this collector token. It may be unknown, revoked, disabled, or from another deployment."
59
+ : "UsageMax returned an incomplete collector status.";
60
+ }
61
+ return view;
62
+ }
63
+
64
+ export async function requestCollectorStatus(endpoint, config, {
65
+ timeout = 15_000,
66
+ fetchImpl = fetch,
67
+ } = {}) {
68
+ let response;
69
+ try {
70
+ response = await fetchImpl(endpoint, {
71
+ method: "GET",
72
+ headers: {
73
+ authorization: `Bearer ${config.token}`,
74
+ ...(config.deviceId ? { "x-usagemax-device-id": config.deviceId } : {}),
75
+ },
76
+ cache: "no-store",
77
+ signal: AbortSignal.timeout(timeout),
78
+ });
79
+ } catch {
80
+ throw new Error("Collector status could not reach UsageMax or timed out. Check your network connection.");
81
+ }
82
+ const body = await response.json().catch(() => null);
83
+ return { httpStatus: response.status, body };
84
+ }
85
+
10
86
  // Only snapshot operations have server receipts. Never automatically replay a
11
87
  // one-use link request or apply this policy to arbitrary POST operations.
12
88
  export async function requestSnapshot(endpoint, config, operation, payload, {