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 +195 -177
- package/package.json +16 -2
- package/src/cli.js +119 -12
- package/src/service.js +67 -5
- package/src/transport.js +76 -0
package/README.md
CHANGED
|
@@ -1,200 +1,218 @@
|
|
|
1
1
|
# UsageMax CLI
|
|
2
2
|
|
|
3
|
-
Connect
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
installation
|
|
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
|
|
31
|
-
bunx usagemax sync
|
|
32
|
-
bunx usagemax sync --
|
|
33
|
-
bunx usagemax
|
|
34
|
-
bunx usagemax
|
|
35
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
The
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 #
|
|
136
|
-
usagemax service status
|
|
137
|
-
usagemax service
|
|
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
|
-
|
|
142
|
-
Scheduler on Windows.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
"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.
|
|
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 ||
|
|
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
|
|
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 (
|
|
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:
|
|
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 ${
|
|
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
|
-
|
|
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: {
|
|
440
|
-
|
|
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.
|
|
89
|
+
const name = `com.UsageMax.sync.${id}`;
|
|
35
90
|
const args = [cli, "service", "run", "--config-dir", directory];
|
|
36
|
-
|
|
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
|
|
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, {
|