usagemax 0.3.3 → 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,200 +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
37
+ ```
38
+
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}]}'
13
45
  ```
14
46
 
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.
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.
21
53
 
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.
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
- ## 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:
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:
131
171
 
132
172
  ```bash
133
173
  bun install -g usagemax
134
174
  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
175
+ usagemax service install --every 30 # 5–1440 minutes
176
+ usagemax service status
177
+ usagemax service uninstall
139
178
  ```
140
179
 
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
-
168
- ## Interrupted uploads and protocol 0.3.1
169
-
170
- Before uploading, the CLI saves the exact run, ordered request payloads and next
171
- checkpoint in the private config. `sync` resumes this journal before scanning new
172
- data. Local snapshots advance only after completion is acknowledged. A resumed
173
- command finishes the saved scan; run `sync` again to collect subsequent changes.
174
- `sync --dry-run` reports pending work without uploading. Do not delete config.json
175
- to retry a failed upload. A successful relink or unlink replaces/removes the local
176
- journal along with its credential.
177
-
178
- Network failures, malformed success responses, HTTP 429 and 5xx get at most five
179
- attempts per request with exponential jitter. Retry-After seconds and HTTP dates
180
- are honored up to 60 seconds; longer waits stop with an instruction to retry later.
181
- Only completion's `snapshot_run_incomplete` HTTP 409 is retried, because server
182
- cleanup can still be pending. Authentication, validation and other conflicts fail
183
- with an actionable message and retain the journal. JSON `accepted` is null for
184
- uploads because lost responses/replays cannot reliably reconstruct that count;
185
- `changedRows` is the local planned count, not a server accounting receipt.
186
-
187
- Each sorted source/day is sent as ordered chunks of at most 100 rows. Each has a
188
- unique partitionId, zero-based chunkIndex and shared chunkCount. The payload hash
189
- covers `{source, day, complete, pricingVersion, chunkIndex, chunkCount, rows}` in
190
- that order. `partitionCount` counts transmitted chunks. The server must accept
191
- this protocol, receipt replays, and finish omitted-row cleanup before completing
192
- an authoritative run.
193
-
194
- An abrupt process kill can leave `collector.lock` in the config directory. The
195
- next command reports the owner PID and exact path. Confirm that process has exited
196
- before removing only that lock file, then rerun sync. Never remove an active lock
197
- or the saved config to recover. Normal completion and handled failures release it.
198
-
199
- See the [collector coverage audit](../../docs/collector-coverage-audit.md) for
200
- the full support matrix and known boundaries.
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.3",
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",
@@ -37,7 +51,7 @@
37
51
  },
38
52
  "scripts": {
39
53
  "test": "node --test src/*.test.js",
40
- "pack:check": "npm pack --dry-run",
54
+ "pack:check": "npm pack --dry-run --ignore-scripts",
41
55
  "prepublishOnly": "npm test"
42
56
  },
43
57
  "dependencies": {
package/src/cli.js CHANGED
@@ -14,17 +14,24 @@ import { prepareArchiveRecovery } from "./archives.js";
14
14
  import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
15
15
  import { stableInstallationId } from "./installation.js";
16
16
  import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
17
- import { requestSnapshot } from "./transport.js";
17
+ import { collectorStatusView, requestCollectorStatus, requestSnapshot } from "./transport.js";
18
18
  import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
19
19
  import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
20
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
+
21
25
  const require = createRequire(import.meta.url);
22
26
  const executeFile = promisify(execFile);
23
- const VERSION = "0.3.3";
27
+ const VERSION = "0.3.6";
24
28
  const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
25
29
  const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
30
+ const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
26
31
  const CONFIG_FILE = "config.json";
27
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;
28
35
 
29
36
  function configDirectory() {
30
37
  if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
@@ -51,18 +58,21 @@ function isLegacyDirectApi(config) {
51
58
  async function readConfig() {
52
59
  try {
53
60
  const parsed = JSON.parse(await readFile(configPath(), "utf8"));
54
- 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;
55
62
  if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
56
63
  if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
57
64
  parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
58
65
  if (isLegacyDirectApi(parsed)) {
59
66
  parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
60
67
  parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
68
+ parsed.statusUrl = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
61
69
  parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
62
70
  await writeConfig(parsed);
63
71
  }
64
72
  parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
65
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");
66
76
  parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
67
77
  || parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
68
78
  return parsed;
@@ -109,6 +119,9 @@ function help() {
109
119
  process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
110
120
  process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
111
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");
112
125
  process.stdout.write(" usagemax service install [--every 15]\n");
113
126
  process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
114
127
  process.stdout.write(" usagemax service status|run|uninstall\n");
@@ -167,7 +180,8 @@ async function link(args) {
167
180
  const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
168
181
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
169
182
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
170
- 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;
171
185
  const previous = await readConfig();
172
186
  const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
173
187
  const headers = { "content-type": "application/json" };
@@ -175,33 +189,36 @@ async function link(args) {
175
189
  const response = await fetch(endpoint, {
176
190
  method: "POST",
177
191
  headers,
178
- body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
192
+ body: JSON.stringify({ code, ...(name ? { name, nameExplicit: true } : {}), platform: platform(), cliVersion: VERSION, deviceId }),
179
193
  signal: AbortSignal.timeout(15_000),
180
194
  });
181
195
  const body = await response.json().catch(() => ({}));
182
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.");
183
197
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
184
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");
185
200
  const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
186
- 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.");
187
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());
188
204
  const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
189
205
  const config = {
190
206
  version: 1,
191
207
  token: body.token,
192
208
  ingestUrl,
193
209
  snapshotUrl,
210
+ statusUrl,
194
211
  revokeUrl,
195
212
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
196
213
  profileHandle,
197
214
  deviceId,
198
- deviceName: name,
215
+ deviceName: savedName,
199
216
  linkedAt: new Date().toISOString(),
200
217
  snapshots: sameAccount ? previous.snapshots : {},
201
218
  };
202
219
  await writeConfig(config);
203
220
  warnVersion(body);
204
- 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`);
205
222
  if (args.includes("--no-sync")) {
206
223
  process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
207
224
  return;
@@ -352,9 +369,37 @@ async function syncPrepared(args, suppliedConfig, recovery) {
352
369
  return result;
353
370
  }
354
371
 
355
- 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 = []) {
356
395
  const config = await readConfig();
396
+ const remote = args.includes("--remote");
397
+ const json = args.includes("--json");
357
398
  if (!config) {
399
+ if (json) {
400
+ process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
401
+ return;
402
+ }
358
403
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
359
404
  return;
360
405
  }
@@ -363,11 +408,70 @@ async function status() {
363
408
  config.deviceId = stableId;
364
409
  await writeConfig(config);
365
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
+ }
366
438
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
367
439
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
368
440
  process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
369
441
  if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
370
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
+ }
371
475
  }
372
476
 
373
477
  async function doctor(args = []) {
@@ -436,8 +540,10 @@ async function removeLink(args = []) {
436
540
  if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
437
541
  const response = await fetch(endpoint, {
438
542
  method: "POST",
439
- headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
440
- body: JSON.stringify({ deviceId: config.deviceId }),
543
+ headers: {
544
+ authorization: `Bearer ${config.token}`,
545
+ "x-usagemax-device-id": config.deviceId,
546
+ },
441
547
  signal: AbortSignal.timeout(15_000),
442
548
  });
443
549
  if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
@@ -474,11 +580,12 @@ async function main() {
474
580
  return;
475
581
  }
476
582
  if (command === "report") return report(args.slice(1));
583
+ if (command === "token" && args[1] === "status") return tokenStatus(args.slice(2));
477
584
  if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
478
585
  return withConfigLock(configDirectory(), async () => {
479
586
  if (command === "link") return link(args.slice(1));
480
587
  if (command === "sync") return sync(args.slice(1));
481
- if (command === "status") return status();
588
+ if (command === "status") return status(args.slice(1));
482
589
  if (command === "doctor") return doctor(args.slice(1));
483
590
  return removeLink(args.slice(1));
484
591
  });
package/src/service.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { access, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { access, chmod, copyFile, mkdir, readFile, readdir, realpath, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
@@ -27,13 +27,73 @@ export function assertDurablePath(path) {
27
27
  }
28
28
  }
29
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
+
30
85
  export function servicePlan({ directory, executable, cli, minutes = 15, platform = process.platform, home = homedir(), uid = process.getuid?.(), configHome = join(home, ".config"), now = Date.now() }) {
31
86
  minutes = intervalMinutes(minutes);
32
87
  for (const path of [directory, executable, cli, home, configHome]) if (/[\x00-\x1f]/.test(path)) throw new Error("Scheduler paths cannot contain control characters.");
33
88
  const id = createHash("sha256").update(directory).digest("hex").slice(0, 12);
34
- const name = `com.usagemax.sync.${id}`;
89
+ const name = `com.UsageMax.sync.${id}`;
35
90
  const args = [cli, "service", "run", "--config-dir", directory];
36
- const command = [executable, ...args];
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];
37
97
  if (platform === "darwin") {
38
98
  const file = join(home, "Library", "LaunchAgents", `${name}.plist`);
39
99
  const domain = `gui/${uid}`;
@@ -88,7 +148,8 @@ export async function manageService(action, { directory, cli, minutes, env = pro
88
148
  const settingsPath = join(directory, "service.json");
89
149
  const settings = await readJson(settingsPath);
90
150
  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 });
151
+ const executable = brandedRuntimePath(directory, platform) || process.execPath;
152
+ const plan = servicePlan({ directory, cli, executable, minutes: minutes ?? settings?.minutes ?? 15, configHome, home, platform });
92
153
  if (action === "status") {
93
154
  let registered = false;
94
155
  try { await executeCommand(plan.probe); registered = true; } catch { /* Not installed, inactive, or unavailable. */ }
@@ -105,8 +166,9 @@ export async function manageService(action, { directory, cli, minutes, env = pro
105
166
  }
106
167
  if (action !== "install") throw new Error("Use service install [--every 15], status, run, or uninstall.");
107
168
  assertDurablePath(await realpath(cli));
108
- assertDurablePath(await realpath(process.execPath));
169
+ if (platform === "darwin" || platform === "win32") assertDurablePath(await realpath(process.execPath));
109
170
  await access(join(directory, "config.json"));
171
+ if (platform === "darwin" || platform === "win32") await ensureBrandedRuntime(directory, process.execPath, platform);
110
172
  // Check the user manager before writing anything. WSL without systemd fails clearly.
111
173
  if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "show-environment"]]);
112
174
  if (settings) {
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, {