usagemax 0.3.2 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -142
- package/package.json +17 -2
- package/src/cli.js +146 -12
- package/src/resume.js +3 -1
- package/src/service.js +212 -0
- package/src/transport.js +76 -0
package/README.md
CHANGED
|
@@ -1,159 +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
|
|
13
37
|
```
|
|
14
38
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
39
|
+
Agent-friendly checks can request JSON and keep the secret out of arguments and
|
|
40
|
+
logs. This example only inspects local source coverage:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
set +x
|
|
44
|
+
bunx usagemax doctor --deep --json | jq '{complete, sources: [.sources[] | {name, status}]}'
|
|
45
|
+
```
|
|
21
46
|
|
|
22
|
-
The
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
47
|
+
The JSON shape is intended for local automation; unsupported or unavailable
|
|
48
|
+
sources remain explicitly reported rather than being inferred.
|
|
49
|
+
|
|
50
|
+
The link code expires after ten minutes and is consumed once. The account-side
|
|
51
|
+
computer name is retained. Pass `--name "Work laptop"` only when the current
|
|
52
|
+
CLI should explicitly override it.
|
|
53
|
+
|
|
54
|
+
Each installation gets a stable random ID and a new write-only collector key.
|
|
55
|
+
Relinking or renaming that installation rotates the key without creating a
|
|
56
|
+
second device. Do not link two installations to the same copied or
|
|
57
|
+
network-mounted log tree; cross-installation duplicate history is ambiguous.
|
|
26
58
|
|
|
27
59
|
## Commands
|
|
28
60
|
|
|
61
|
+
```text
|
|
62
|
+
usagemax Sync changed local usage
|
|
63
|
+
usagemax link <code> [options] Link and sync a computer
|
|
64
|
+
usagemax sync [options] Reconcile local usage once
|
|
65
|
+
usagemax status Show local link state
|
|
66
|
+
usagemax doctor Check discovered sources
|
|
67
|
+
usagemax report [ccusage args] Run a local ccusage report
|
|
68
|
+
usagemax token status Diagnose a key piped on stdin
|
|
69
|
+
usagemax service install Opt into periodic OS checkpoints
|
|
70
|
+
usagemax service status|run|uninstall
|
|
71
|
+
usagemax unlink [--revoke] Remove local credentials
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Useful options:
|
|
75
|
+
|
|
29
76
|
```bash
|
|
30
|
-
bunx usagemax
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
88
|
+
and supports its 16 adapters: Amp, Claude Code, Codebuff, Codex, GitHub Copilot
|
|
89
|
+
CLI, Factory Droid, Gemini CLI, Goose, Grok Build, Hermes, Kilo Code, Kimi CLI,
|
|
90
|
+
OpenClaw, OpenCode, Pi, and Qwen Code. Named Pi-format stores are discovered as
|
|
91
|
+
well.
|
|
92
|
+
|
|
93
|
+
The collector follows the supported provider environment overrides and bounded
|
|
94
|
+
home locations. It recognizes Claude Desktop sessions, `.cc-mirror`, renamed
|
|
95
|
+
Claude/Codex backup folders, and supported Windows homes from WSL. In WSL, use
|
|
96
|
+
one collector for the Windows provider homes it can read instead of linking the
|
|
97
|
+
same history again from Windows.
|
|
98
|
+
|
|
99
|
+
Full scans catalog retained history from 2024 onward. `sync --archives` safely
|
|
100
|
+
extracts supported Claude JSONL members into a private temporary directory and
|
|
101
|
+
removes them after reconciliation. Normal runs do not crawl the whole disk or
|
|
102
|
+
unpack archives.
|
|
103
|
+
|
|
104
|
+
Cursor, Windsurf, Aider, Continue, Cline, Roo Code, hosted agents, and direct
|
|
105
|
+
provider API traffic may not leave a stable local token ledger. Use UsageMax's
|
|
106
|
+
native or OTLP/HTTP JSON contract, or a provider billing export, when local
|
|
107
|
+
evidence is unavailable. Unsupported usage is never guessed.
|
|
108
|
+
|
|
109
|
+
## Safe collector diagnostics
|
|
110
|
+
|
|
111
|
+
An advanced key from **Advanced · custom telemetry collector** is a
|
|
112
|
+
`umx_` prefix followed by 64 lowercase hexadecimal characters. Pipe it through
|
|
113
|
+
stdin; never pass it as an argument or put it in a URL:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
set +x
|
|
117
|
+
printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
|
|
118
|
+
| bunx usagemax@latest token status \
|
|
119
|
+
--device-id "$USAGEMAX_INSTALLATION_ID" \
|
|
120
|
+
--json
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The `token status` command is included in CLI `0.3.6`. If the public npm tag
|
|
124
|
+
does not yet contain `0.3.6`, run `node packages/cli/src/cli.js token status`
|
|
125
|
+
from the UsageMax repository until that release is published.
|
|
126
|
+
|
|
127
|
+
The response is read-only and contains only status, type, scopes, profile/name,
|
|
128
|
+
activation state, and a binding result of `unbound`, `bound`, `matched`, or
|
|
129
|
+
`mismatch`. It never returns the token, hash, or raw authorized UUID.
|
|
130
|
+
|
|
131
|
+
For a numeric-only result suitable for a smoke check:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
set +x
|
|
135
|
+
printf '%s' "$USAGEMAX_COLLECTOR_TOKEN" \
|
|
136
|
+
| bunx usagemax@latest token status \
|
|
137
|
+
--device-id "$USAGEMAX_INSTALLATION_ID" --json \
|
|
138
|
+
| jq -r '[.httpStatus, (if .ingestAuthorized then 1 else 0 end)] | @tsv'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`200 1` is active and ingestion-authorized. `200 0` is recognized but blocked;
|
|
142
|
+
inspect `status` and `scopeStatus` in the unfiltered JSON. `409 0` is a device
|
|
143
|
+
binding mismatch. `401 0` means the format/key was rejected.
|
|
144
|
+
|
|
145
|
+
New advanced keys are active immediately and need no activation or propagation.
|
|
146
|
+
They bind on their first valid write. Linked CLI keys are bound during the link
|
|
147
|
+
exchange. A `401` means the key format is invalid or the key is unknown,
|
|
148
|
+
revoked, disabled, or from another deployment. A `409` means the supplied
|
|
149
|
+
installation does not match. A recognized key missing `telemetry:write` has
|
|
150
|
+
`status: scope_missing`, `scopeStatus: missing_telemetry_write`, and
|
|
151
|
+
`ingestAuthorized: false`.
|
|
152
|
+
|
|
153
|
+
For a write-path smoke check, the root README includes a `curl` request that
|
|
154
|
+
sends one `agent_state` event with all token counters and `costMicros` set to
|
|
155
|
+
zero. It is observability-only: `agent_state` does not update accounting, and
|
|
156
|
+
the probe may bind an otherwise unbound advanced key.
|
|
157
|
+
|
|
158
|
+
## Privacy and resource use
|
|
159
|
+
|
|
160
|
+
The CLI uploads aggregate token counters, provider/model names, source names,
|
|
161
|
+
dates, cost provenance, coverage state, and opaque SHA-256 session identities.
|
|
162
|
+
It never uploads prompts, completions, source code, file contents, project
|
|
163
|
+
paths, tool payloads, or provider credentials.
|
|
164
|
+
|
|
165
|
+
Every sync is one-shot. A complete unchanged inventory can skip parsing and
|
|
166
|
+
uploading; date rollover, new sources, a weekly reconciliation, `--full`, or
|
|
167
|
+
`--archives` triggers the appropriate bounded scan. Decreases and deletions are
|
|
168
|
+
protected while coverage is incomplete.
|
|
169
|
+
|
|
170
|
+
Optional scheduling invokes the same process at low priority:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
bun install -g usagemax
|
|
174
|
+
usagemax service install # approximately every 15 minutes
|
|
175
|
+
usagemax service install --every 30 # 5–1440 minutes
|
|
176
|
+
usagemax service status
|
|
177
|
+
usagemax service uninstall
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The scheduler uses a user LaunchAgent on macOS, a user systemd timer on
|
|
181
|
+
Linux/WSL, and Task Scheduler on Windows. It does not install a resident
|
|
182
|
+
watcher, wake a sleeping computer, download packages per run, or replay missed
|
|
183
|
+
intervals. Failures back off for up to six hours and the collector lock prevents
|
|
184
|
+
overlapping syncs.
|
|
185
|
+
|
|
186
|
+
## Recovery and checkpoints
|
|
187
|
+
|
|
188
|
+
Uploads are idempotent and resumable. Before network I/O, the CLI saves a
|
|
189
|
+
bounded journal containing the current run, ordered operations, and next
|
|
190
|
+
checkpoint in the user-only config directory. If a process or network request
|
|
191
|
+
fails, rerun:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
usagemax sync
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
An expired run can be restarted with `sync --restart`; already accepted usage
|
|
198
|
+
remains safe. Do not delete `config.json` to retry a failed upload. See the
|
|
199
|
+
[operations runbook](https://github.com/SYMBaiEX/usagemax/blob/main/docs/operations-runbook.md)
|
|
200
|
+
for recovery guidance.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
From the repository root:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
bun install
|
|
208
|
+
bun run --cwd packages/cli test
|
|
209
|
+
bun run --cwd packages/cli pack:check # dry-run; lifecycle scripts disabled
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`pack:check` only inspects the local archive shape. It does not publish, contact
|
|
213
|
+
the npm registry, or establish that any registry tag contains this version.
|
|
214
|
+
|
|
215
|
+
The package is MIT-licensed. See the included [LICENSE](LICENSE), the
|
|
216
|
+
repository [LICENSE](https://github.com/SYMBaiEX/usagemax/blob/main/LICENSE),
|
|
217
|
+
[security policy](https://github.com/SYMBaiEX/usagemax/blob/main/SECURITY.md),
|
|
218
|
+
and [contributing guide](https://github.com/SYMBaiEX/usagemax/blob/main/CONTRIBUTING.md).
|
package/package.json
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "usagemax",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Link local coding-agent usage to your UsageMax profile",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"usagemax",
|
|
7
|
+
"ai-usage",
|
|
8
|
+
"usage-analytics",
|
|
9
|
+
"coding-agents",
|
|
10
|
+
"agent-usage",
|
|
11
|
+
"telemetry",
|
|
12
|
+
"observability",
|
|
13
|
+
"usage",
|
|
14
|
+
"ccusage",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"codex",
|
|
17
|
+
"privacy"
|
|
18
|
+
],
|
|
5
19
|
"license": "MIT",
|
|
6
20
|
"author": "UsageMax",
|
|
7
21
|
"homepage": "https://usagemax.com",
|
|
@@ -25,6 +39,7 @@
|
|
|
25
39
|
"src/sources.js",
|
|
26
40
|
"src/transport.js",
|
|
27
41
|
"src/resume.js",
|
|
42
|
+
"src/service.js",
|
|
28
43
|
"README.md",
|
|
29
44
|
"LICENSE"
|
|
30
45
|
],
|
|
@@ -36,7 +51,7 @@
|
|
|
36
51
|
},
|
|
37
52
|
"scripts": {
|
|
38
53
|
"test": "node --test src/*.test.js",
|
|
39
|
-
"pack:check": "npm pack --dry-run",
|
|
54
|
+
"pack:check": "npm pack --dry-run --ignore-scripts",
|
|
40
55
|
"prepublishOnly": "npm test"
|
|
41
56
|
},
|
|
42
57
|
"dependencies": {
|
package/src/cli.js
CHANGED
|
@@ -8,21 +8,30 @@ import { homedir, platform } from "node:os";
|
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import process from "node:process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
11
12
|
|
|
12
13
|
import { prepareArchiveRecovery } from "./archives.js";
|
|
13
14
|
import { buildSessionPlan, buildSnapshotPlan, normalizeLinkCode, reportDateArgs, scanPolicy, sourceSummary, validHttpsUrl } from "./core.js";
|
|
14
15
|
import { stableInstallationId } from "./installation.js";
|
|
15
|
-
import {
|
|
16
|
+
import { intervalMinutes, manageService, runScheduledSync } from "./service.js";
|
|
17
|
+
import { collectorStatusView, requestCollectorStatus, requestSnapshot } from "./transport.js";
|
|
16
18
|
import { resumeUpload, restartExpiredUpload, withConfigLock } from "./resume.js";
|
|
17
19
|
import { CCUSAGE_VERSION, ccusageEnvironment, ccusageHome, discoverProviderArchives, SOURCE_INVENTORY_VERSION, sourceInventory, SUPPORTED_SOURCES } from "./sources.js";
|
|
18
20
|
|
|
21
|
+
// Make the short-lived collector recognizable in Activity Monitor and `ps`.
|
|
22
|
+
// Windows may still display the underlying node.exe image name in Task Manager.
|
|
23
|
+
process.title = "UsageMax";
|
|
24
|
+
|
|
19
25
|
const require = createRequire(import.meta.url);
|
|
20
26
|
const executeFile = promisify(execFile);
|
|
21
|
-
const VERSION = "0.3.
|
|
27
|
+
const VERSION = "0.3.6";
|
|
22
28
|
const PUBLIC_API_ORIGIN = "https://usagemax.com/api";
|
|
23
29
|
const DEFAULT_LINK_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/link`;
|
|
30
|
+
const DEFAULT_STATUS_ENDPOINT = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
|
|
24
31
|
const CONFIG_FILE = "config.json";
|
|
25
32
|
const MAX_REPORT_BYTES = 100 * 1024 * 1024;
|
|
33
|
+
const TOKEN_PATTERN = /^umx_[a-f0-9]{64}$/;
|
|
34
|
+
const DEVICE_PATTERN = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
|
26
35
|
|
|
27
36
|
function configDirectory() {
|
|
28
37
|
if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
|
|
@@ -49,18 +58,21 @@ function isLegacyDirectApi(config) {
|
|
|
49
58
|
async function readConfig() {
|
|
50
59
|
try {
|
|
51
60
|
const parsed = JSON.parse(await readFile(configPath(), "utf8"));
|
|
52
|
-
if (!parsed || parsed.version !== 1 ||
|
|
61
|
+
if (!parsed || parsed.version !== 1 || !TOKEN_PATTERN.test(parsed.token || "")) return null;
|
|
53
62
|
if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
|
|
54
63
|
if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
|
|
55
64
|
parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
|
|
56
65
|
if (isLegacyDirectApi(parsed)) {
|
|
57
66
|
parsed.ingestUrl = `${PUBLIC_API_ORIGIN}/v1/telemetry/llm`;
|
|
58
67
|
parsed.snapshotUrl = `${PUBLIC_API_ORIGIN}/v2/usage/snapshots`;
|
|
68
|
+
parsed.statusUrl = `${PUBLIC_API_ORIGIN}/v1/devices/status`;
|
|
59
69
|
parsed.revokeUrl = `${PUBLIC_API_ORIGIN}/v1/devices/revoke`;
|
|
60
70
|
await writeConfig(parsed);
|
|
61
71
|
}
|
|
62
72
|
parsed.snapshotUrl = validHttpsUrl(parsed.snapshotUrl, { allowLocalhost: true })
|
|
63
73
|
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
74
|
+
parsed.statusUrl = validHttpsUrl(parsed.statusUrl, { allowLocalhost: true })
|
|
75
|
+
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
|
|
64
76
|
parsed.revokeUrl = validHttpsUrl(parsed.revokeUrl, { allowLocalhost: true })
|
|
65
77
|
|| parsed.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
|
|
66
78
|
return parsed;
|
|
@@ -107,6 +119,12 @@ function help() {
|
|
|
107
119
|
process.stdout.write(" usagemax sync [--full] [--archives] [--restart] [--dry-run] [--explain] [--json]\n");
|
|
108
120
|
process.stdout.write(" Reconcile once; --archives performs one-time recovery\n");
|
|
109
121
|
process.stdout.write(" usagemax status Show local link status\n");
|
|
122
|
+
process.stdout.write(" --remote [--json] Verify the stored collector credential without printing it\n");
|
|
123
|
+
process.stdout.write(" usagemax token status [--device-id <uuid>] [--json]\n");
|
|
124
|
+
process.stdout.write(" Diagnose a key piped on stdin; never pass it as an argument\n");
|
|
125
|
+
process.stdout.write(" usagemax service install [--every 15]\n");
|
|
126
|
+
process.stdout.write(" Opt into lightweight OS-scheduled sync\n");
|
|
127
|
+
process.stdout.write(" usagemax service status|run|uninstall\n");
|
|
110
128
|
process.stdout.write(" usagemax doctor [--deep] [--json]\n");
|
|
111
129
|
process.stdout.write(" Check source coverage; --deep parses full history\n");
|
|
112
130
|
process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
|
|
@@ -127,6 +145,8 @@ async function ccusageJson(config, { full = false, env } = {}) {
|
|
|
127
145
|
const { stdout } = await executeFile(process.execPath, args, {
|
|
128
146
|
encoding: "utf8",
|
|
129
147
|
maxBuffer: MAX_REPORT_BYTES,
|
|
148
|
+
timeout: 10 * 60 * 1000,
|
|
149
|
+
killSignal: "SIGKILL",
|
|
130
150
|
env: { ...(env || await ccusageEnvironment()), NO_COLOR: "1" },
|
|
131
151
|
});
|
|
132
152
|
return JSON.parse(stdout);
|
|
@@ -160,7 +180,8 @@ async function link(args) {
|
|
|
160
180
|
const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
|
|
161
181
|
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
162
182
|
if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
|
|
163
|
-
const
|
|
183
|
+
const requestedName = option(args, "--name");
|
|
184
|
+
const name = requestedName?.trim().slice(0, 80) || undefined;
|
|
164
185
|
const previous = await readConfig();
|
|
165
186
|
const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
|
|
166
187
|
const headers = { "content-type": "application/json" };
|
|
@@ -168,33 +189,36 @@ async function link(args) {
|
|
|
168
189
|
const response = await fetch(endpoint, {
|
|
169
190
|
method: "POST",
|
|
170
191
|
headers,
|
|
171
|
-
body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
|
|
192
|
+
body: JSON.stringify({ code, ...(name ? { name, nameExplicit: true } : {}), platform: platform(), cliVersion: VERSION, deviceId }),
|
|
172
193
|
signal: AbortSignal.timeout(15_000),
|
|
173
194
|
});
|
|
174
195
|
const body = await response.json().catch(() => ({}));
|
|
175
196
|
if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
|
|
176
197
|
const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
|
|
177
198
|
const snapshotUrl = validHttpsUrl(body.snapshotUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v2/usage/snapshots");
|
|
199
|
+
const statusUrl = validHttpsUrl(body.statusUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
|
|
178
200
|
const revokeUrl = validHttpsUrl(body.revokeUrl, { allowLocalhost: true }) || ingestUrl?.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/revoke");
|
|
179
|
-
if (
|
|
201
|
+
if (!TOKEN_PATTERN.test(body.token || "") || !ingestUrl || !snapshotUrl || !statusUrl || !revokeUrl) throw new Error("UsageMax returned an invalid link response.");
|
|
180
202
|
const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
|
|
203
|
+
const savedName = typeof body.deviceName === "string" && body.deviceName.trim() ? body.deviceName.trim().slice(0, 80) : (name || deviceLabel());
|
|
181
204
|
const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
|
|
182
205
|
const config = {
|
|
183
206
|
version: 1,
|
|
184
207
|
token: body.token,
|
|
185
208
|
ingestUrl,
|
|
186
209
|
snapshotUrl,
|
|
210
|
+
statusUrl,
|
|
187
211
|
revokeUrl,
|
|
188
212
|
profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
|
|
189
213
|
profileHandle,
|
|
190
214
|
deviceId,
|
|
191
|
-
deviceName:
|
|
215
|
+
deviceName: savedName,
|
|
192
216
|
linkedAt: new Date().toISOString(),
|
|
193
217
|
snapshots: sameAccount ? previous.snapshots : {},
|
|
194
218
|
};
|
|
195
219
|
await writeConfig(config);
|
|
196
220
|
warnVersion(body);
|
|
197
|
-
process.stdout.write(`Linked ${
|
|
221
|
+
process.stdout.write(`Linked ${savedName} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
|
|
198
222
|
if (args.includes("--no-sync")) {
|
|
199
223
|
process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
|
|
200
224
|
return;
|
|
@@ -345,9 +369,37 @@ async function syncPrepared(args, suppliedConfig, recovery) {
|
|
|
345
369
|
return result;
|
|
346
370
|
}
|
|
347
371
|
|
|
348
|
-
|
|
372
|
+
function collectorStatusEndpoint(config) {
|
|
373
|
+
return validHttpsUrl(process.env.USAGEMAX_STATUS_ENDPOINT, { allowLocalhost: true })
|
|
374
|
+
|| validHttpsUrl(config.statusUrl, { allowLocalhost: true })
|
|
375
|
+
|| config.ingestUrl.replace(/\/v1\/telemetry\/llm$/, "/v1/devices/status");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function printRemoteStatus(view) {
|
|
379
|
+
process.stdout.write(`Remote credential: ${view.status || "unavailable"}${view.httpStatus ? ` (HTTP ${view.httpStatus})` : ""}\n`);
|
|
380
|
+
if (view.reason) process.stdout.write(`${view.reason}\n`);
|
|
381
|
+
if (view.credentialType) process.stdout.write(`Type: ${view.credentialType}${view.writeOnly ? "; write-only" : ""}\n`);
|
|
382
|
+
if (view.scopes?.length) process.stdout.write(`Scopes: ${view.scopes.join(", ")}\n`);
|
|
383
|
+
if (view.scopeStatus) process.stdout.write(`Ingest scope: ${view.scopeStatus === "valid" ? "authorized" : "missing telemetry:write"}\n`);
|
|
384
|
+
if (view.deviceBinding) process.stdout.write(`Device binding: ${view.deviceBinding}\n`);
|
|
385
|
+
if (view.profileHandle) process.stdout.write(`Profile: @${view.profileHandle}\n`);
|
|
386
|
+
if (view.deviceName) process.stdout.write(`Computer: ${view.deviceName}\n`);
|
|
387
|
+
if (view.platform || view.cliVersion) process.stdout.write(`Runtime: ${view.platform || "unknown"}${view.cliVersion ? ` · CLI ${view.cliVersion}` : ""}\n`);
|
|
388
|
+
if (view.activation) process.stdout.write(`Activation: ${view.activation}\n`);
|
|
389
|
+
if (view.expiresAt === null) process.stdout.write("Expiration: none\n");
|
|
390
|
+
if (view.lastSuccessAt) process.stdout.write(`Last accepted write: ${new Date(view.lastSuccessAt).toISOString()}\n`);
|
|
391
|
+
if (view.lastFailureAt) process.stdout.write(`Last rejected write: ${new Date(view.lastFailureAt).toISOString()}${view.lastFailureCode ? ` · ${view.lastFailureCode}` : ""}\n`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function status(args = []) {
|
|
349
395
|
const config = await readConfig();
|
|
396
|
+
const remote = args.includes("--remote");
|
|
397
|
+
const json = args.includes("--json");
|
|
350
398
|
if (!config) {
|
|
399
|
+
if (json) {
|
|
400
|
+
process.stdout.write(`${JSON.stringify({ linked: false, remote: remote ? { status: "not_linked" } : undefined })}\n`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
351
403
|
process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
|
|
352
404
|
return;
|
|
353
405
|
}
|
|
@@ -356,11 +408,70 @@ async function status() {
|
|
|
356
408
|
config.deviceId = stableId;
|
|
357
409
|
await writeConfig(config);
|
|
358
410
|
}
|
|
411
|
+
const local = {
|
|
412
|
+
linked: true,
|
|
413
|
+
deviceName: config.deviceName || deviceLabel(),
|
|
414
|
+
profileHandle: config.profileHandle || null,
|
|
415
|
+
deviceIdConfigured: Boolean(config.deviceId),
|
|
416
|
+
lastSyncAt: config.lastSyncAt || null,
|
|
417
|
+
lastFullSyncAt: config.lastFullSyncAt || null,
|
|
418
|
+
pendingSync: config.pendingSync?.runId || null,
|
|
419
|
+
profileUrl: config.profileUrl || "https://usagemax.com/account",
|
|
420
|
+
};
|
|
421
|
+
let remoteView;
|
|
422
|
+
if (remote) {
|
|
423
|
+
try {
|
|
424
|
+
const result = await requestCollectorStatus(collectorStatusEndpoint(config), config);
|
|
425
|
+
remoteView = collectorStatusView(result.httpStatus, result.body, config.token);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
remoteView = { tokenFormat: "valid", status: "unavailable", reason: error instanceof Error ? error.message : "Collector status unavailable." };
|
|
428
|
+
}
|
|
429
|
+
if (json) {
|
|
430
|
+
process.stdout.write(`${JSON.stringify({ ...local, remote: remoteView })}\n`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (json) {
|
|
435
|
+
process.stdout.write(`${JSON.stringify(local)}\n`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
359
438
|
process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
|
|
360
439
|
process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
|
|
361
440
|
process.stdout.write(`Last full reconciliation: ${config.lastFullSyncAt || "never"}\n`);
|
|
362
441
|
if (config.pendingSync) process.stdout.write(`Pending sync: ${config.pendingSync.runId}; rerun sync to resume\n`);
|
|
363
442
|
process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
|
|
443
|
+
if (remote) printRemoteStatus(remoteView);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function readTokenFromStdin() {
|
|
447
|
+
if (process.stdin.isTTY) throw new Error("Pipe the collector token on stdin; never pass it as a command-line argument.");
|
|
448
|
+
// fs.promises.readFile does not consistently accept file descriptor 0
|
|
449
|
+
// across the Node versions supported by the CLI. Read the pipe as a stream
|
|
450
|
+
// instead, and bound it so an accidental large stdin cannot be buffered.
|
|
451
|
+
let input = "";
|
|
452
|
+
for await (const chunk of process.stdin) {
|
|
453
|
+
input += String(chunk);
|
|
454
|
+
if (input.length > 256) throw new Error("stdin did not contain a valid UsageMax collector token.");
|
|
455
|
+
}
|
|
456
|
+
const token = input.trim();
|
|
457
|
+
if (!TOKEN_PATTERN.test(token)) throw new Error("stdin did not contain a valid UsageMax collector token (expected umx_ plus 64 lowercase hexadecimal characters).");
|
|
458
|
+
return token;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function tokenStatus(args = []) {
|
|
462
|
+
const requestedDeviceId = option(args, "--device-id");
|
|
463
|
+
if (requestedDeviceId && !DEVICE_PATTERN.test(requestedDeviceId)) throw new Error("--device-id must be a UUID.");
|
|
464
|
+
const token = await readTokenFromStdin();
|
|
465
|
+
const configuredEndpoint = process.env.USAGEMAX_STATUS_ENDPOINT || DEFAULT_STATUS_ENDPOINT;
|
|
466
|
+
const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
|
|
467
|
+
if (!endpoint) throw new Error("USAGEMAX_STATUS_ENDPOINT must use HTTPS, except for localhost development.");
|
|
468
|
+
const result = await requestCollectorStatus(endpoint, { token, deviceId: requestedDeviceId });
|
|
469
|
+
const view = collectorStatusView(result.httpStatus, result.body, token);
|
|
470
|
+
if (args.includes("--json")) process.stdout.write(`${JSON.stringify(view)}\n`);
|
|
471
|
+
else {
|
|
472
|
+
process.stdout.write("Credential format: valid (umx_ + 64 lowercase hexadecimal characters)\n");
|
|
473
|
+
printRemoteStatus(view);
|
|
474
|
+
}
|
|
364
475
|
}
|
|
365
476
|
|
|
366
477
|
async function doctor(args = []) {
|
|
@@ -429,8 +540,10 @@ async function removeLink(args = []) {
|
|
|
429
540
|
if (!endpoint) throw new Error("This collector does not have a valid revoke endpoint. Revoke it at https://usagemax.com/account.");
|
|
430
541
|
const response = await fetch(endpoint, {
|
|
431
542
|
method: "POST",
|
|
432
|
-
headers: {
|
|
433
|
-
|
|
543
|
+
headers: {
|
|
544
|
+
authorization: `Bearer ${config.token}`,
|
|
545
|
+
"x-usagemax-device-id": config.deviceId,
|
|
546
|
+
},
|
|
434
547
|
signal: AbortSignal.timeout(15_000),
|
|
435
548
|
});
|
|
436
549
|
if (!response.ok) throw new Error("UsageMax could not revoke this collector. It remains linked locally.");
|
|
@@ -446,12 +559,33 @@ async function main() {
|
|
|
446
559
|
const command = args[0] || "sync";
|
|
447
560
|
if (["--help", "-h", "help"].includes(command)) return help();
|
|
448
561
|
if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
|
|
562
|
+
if (command === "service") {
|
|
563
|
+
const action = args[1] || "status";
|
|
564
|
+
const directory = option(args, "--config-dir") || configDirectory();
|
|
565
|
+
process.env.USAGEMAX_CONFIG_DIR = directory;
|
|
566
|
+
let result;
|
|
567
|
+
if (action === "run") {
|
|
568
|
+
result = await runScheduledSync(directory, () => withConfigLock(directory, () => sync(["--json"])));
|
|
569
|
+
if (result.status === "error") process.exitCode = 1;
|
|
570
|
+
} else if (action === "status") {
|
|
571
|
+
result = await manageService(action, { directory, cli: fileURLToPath(import.meta.url) });
|
|
572
|
+
} else {
|
|
573
|
+
result = await withConfigLock(directory, async () => {
|
|
574
|
+
if (action === "install" && !await readConfig()) throw new Error("Link this computer before enabling automatic sync.");
|
|
575
|
+
if (args.includes("--every") && option(args, "--every") === undefined) throw new Error("--every requires a number of minutes.");
|
|
576
|
+
return manageService(action, { directory, cli: fileURLToPath(import.meta.url), minutes: args.includes("--every") ? intervalMinutes(option(args, "--every")) : undefined });
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
449
582
|
if (command === "report") return report(args.slice(1));
|
|
583
|
+
if (command === "token" && args[1] === "status") return tokenStatus(args.slice(2));
|
|
450
584
|
if (["link", "sync", "status", "doctor", "unlink"].includes(command)) {
|
|
451
585
|
return withConfigLock(configDirectory(), async () => {
|
|
452
586
|
if (command === "link") return link(args.slice(1));
|
|
453
587
|
if (command === "sync") return sync(args.slice(1));
|
|
454
|
-
if (command === "status") return status();
|
|
588
|
+
if (command === "status") return status(args.slice(1));
|
|
455
589
|
if (command === "doctor") return doctor(args.slice(1));
|
|
456
590
|
return removeLink(args.slice(1));
|
|
457
591
|
});
|
package/src/resume.js
CHANGED
|
@@ -48,7 +48,9 @@ export async function withConfigLock(directory, action) {
|
|
|
48
48
|
} catch (error) {
|
|
49
49
|
if (error.code !== "EEXIST") throw error;
|
|
50
50
|
const owner = (await readFile(path, "utf8").catch(() => "unknown")).trim();
|
|
51
|
-
|
|
51
|
+
const busy = new Error(`Collector config is locked by PID ${/^\d+$/.test(owner) ? owner : "unknown"}. If that process has exited, remove only ${path} and rerun sync; keep config.json for resume.`);
|
|
52
|
+
busy.code = "USAGEMAX_BUSY";
|
|
53
|
+
throw busy;
|
|
52
54
|
}
|
|
53
55
|
try {
|
|
54
56
|
return await action();
|
package/src/service.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { access, chmod, copyFile, mkdir, readFile, readdir, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execute = promisify(execFile);
|
|
9
|
+
const ENV_KEYS = ["PATH", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA", "WSL_DISTRO_NAME", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "OPENCODE_DATA_DIR", "AMP_DATA_DIR", "DROID_SESSIONS_DIR", "CODEBUFF_DATA_DIR", "HERMES_HOME", "PI_AGENT_DIR", "GOOSE_PATH_ROOT", "OPENCLAW_DIR", "KILO_DATA_DIR", "KIMI_DATA_DIR", "QWEN_DATA_DIR", "GEMINI_DATA_DIR", "GROK_HOME", "COPILOT_OTEL_FILE_EXPORTER_PATH", "USAGEMAX_ADDITIONAL_HOME", "USAGEMAX_WSL_USERS_DIR", "CCUSAGE_MODEL_ALIASES"];
|
|
10
|
+
const xml = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
11
|
+
const unitQuote = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%").replaceAll("$", () => "$$")}"`;
|
|
12
|
+
const windowsQuote = (value) => `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
|
|
13
|
+
|
|
14
|
+
export function intervalMinutes(value = 15) {
|
|
15
|
+
if (!/^\d+$/.test(String(value)) || Number(value) < 5 || Number(value) > 1440) throw new Error("--every must be an integer from 5 to 1440 minutes.");
|
|
16
|
+
return Number(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function capturedEnvironment(env) {
|
|
20
|
+
// Never persist the whole shell environment: it can contain provider secrets.
|
|
21
|
+
return Object.fromEntries(ENV_KEYS.filter((key) => typeof env[key] === "string").map((key) => [key, env[key]]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertDurablePath(path) {
|
|
25
|
+
if (!isAbsolute(path) || /[\x00-\x1f]/.test(path) || /\/(?:_npx|cache|fnm_multishells)\//i.test(path.replaceAll("\\", "/"))) {
|
|
26
|
+
throw new Error("Automatic sync needs a persistent installation. Run `bun install -g usagemax`, then `usagemax service install`; do not install a scheduler from bunx/npx caches.");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function brandedRuntimePath(directory, platform = process.platform) {
|
|
31
|
+
if (platform !== "darwin" && platform !== "win32") return null;
|
|
32
|
+
return platform === "win32"
|
|
33
|
+
? join(directory, "runtime", "UsageMax.exe")
|
|
34
|
+
: join(directory, "runtime", "bin", "UsageMax");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Give scheduled jobs a stable product-facing image name on platforms where
|
|
39
|
+
* the JavaScript runtime otherwise appears as `node` in process browsers.
|
|
40
|
+
* The copied runtime is intentionally private to this installation and is
|
|
41
|
+
* refreshed on every service install, so upgrading Node or UsageMax never
|
|
42
|
+
* leaves the scheduler pointing at an ephemeral cache path.
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureBrandedRuntime(directory, executable, platform = process.platform) {
|
|
45
|
+
const target = brandedRuntimePath(directory, platform);
|
|
46
|
+
if (!target) return executable;
|
|
47
|
+
const source = await realpath(executable);
|
|
48
|
+
const runtimeBinDirectory = dirname(target);
|
|
49
|
+
if (source === target) return target;
|
|
50
|
+
await mkdir(runtimeBinDirectory, { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = join(runtimeBinDirectory, `.${platform === "win32" ? "UsageMax.exe" : "UsageMax"}.${randomUUID()}.tmp`);
|
|
52
|
+
try {
|
|
53
|
+
await copyFile(source, temporary);
|
|
54
|
+
if (platform !== "win32") await chmod(temporary, 0o700);
|
|
55
|
+
await rename(temporary, target);
|
|
56
|
+
if (platform === "darwin") {
|
|
57
|
+
// Homebrew Node uses @rpath/libnode.<n>.dylib next to its bin folder;
|
|
58
|
+
// the official Node distribution is self-contained. Copy only adjacent
|
|
59
|
+
// dylibs when they exist, keeping both layouts runnable.
|
|
60
|
+
const sourceLibraryDirectory = resolve(dirname(source), "../lib");
|
|
61
|
+
const runtimeLibraryDirectory = resolve(runtimeBinDirectory, "../lib");
|
|
62
|
+
const sourceLibraries = await readdir(sourceLibraryDirectory, { withFileTypes: true }).catch((error) => {
|
|
63
|
+
if (error?.code === "ENOENT") return [];
|
|
64
|
+
throw error;
|
|
65
|
+
});
|
|
66
|
+
const libraries = sourceLibraries.filter((entry) => entry.isFile() && entry.name.endsWith(".dylib"));
|
|
67
|
+
if (libraries.length) await mkdir(runtimeLibraryDirectory, { recursive: true, mode: 0o700 });
|
|
68
|
+
for (const library of libraries) {
|
|
69
|
+
const libraryTemporary = join(runtimeLibraryDirectory, `.${library.name}.${randomUUID()}.tmp`);
|
|
70
|
+
try {
|
|
71
|
+
await copyFile(join(sourceLibraryDirectory, library.name), libraryTemporary);
|
|
72
|
+
await chmod(libraryTemporary, 0o700);
|
|
73
|
+
await rename(libraryTemporary, join(runtimeLibraryDirectory, library.name));
|
|
74
|
+
} finally {
|
|
75
|
+
await unlink(libraryTemporary).catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
await unlink(temporary).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
return target;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function servicePlan({ directory, executable, cli, minutes = 15, platform = process.platform, home = homedir(), uid = process.getuid?.(), configHome = join(home, ".config"), now = Date.now() }) {
|
|
86
|
+
minutes = intervalMinutes(minutes);
|
|
87
|
+
for (const path of [directory, executable, cli, home, configHome]) if (/[\x00-\x1f]/.test(path)) throw new Error("Scheduler paths cannot contain control characters.");
|
|
88
|
+
const id = createHash("sha256").update(directory).digest("hex").slice(0, 12);
|
|
89
|
+
const name = `com.UsageMax.sync.${id}`;
|
|
90
|
+
const args = [cli, "service", "run", "--config-dir", directory];
|
|
91
|
+
// Linux can execute the published shebang entry point directly. macOS and
|
|
92
|
+
// Windows launch the staged UsageMax runtime so process browsers do not show
|
|
93
|
+
// the generic JavaScript runtime image name.
|
|
94
|
+
const command = platform === "win32" || platform === "darwin"
|
|
95
|
+
? [executable, ...args]
|
|
96
|
+
: [cli, "service", "run", "--config-dir", directory];
|
|
97
|
+
if (platform === "darwin") {
|
|
98
|
+
const file = join(home, "Library", "LaunchAgents", `${name}.plist`);
|
|
99
|
+
const domain = `gui/${uid}`;
|
|
100
|
+
return { backend: "launchd", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
|
|
101
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
102
|
+
<plist version="1.0"><dict><key>Label</key><string>${name}</string>
|
|
103
|
+
<key>ProgramArguments</key><array>${command.map((s) => `<string>${xml(s)}</string>`).join("")}</array>
|
|
104
|
+
<key>WorkingDirectory</key><string>${xml(home)}</string>
|
|
105
|
+
<key>StartInterval</key><integer>${minutes * 60 + parseInt(id.slice(0, 4), 16) % 60}</integer>
|
|
106
|
+
<key>ProcessType</key><string>Background</string><key>Nice</key><integer>10</integer>
|
|
107
|
+
<key>LowPriorityIO</key><true/><key>StandardOutPath</key><string>/dev/null</string>
|
|
108
|
+
<key>StandardErrorPath</key><string>/dev/null</string></dict></plist>
|
|
109
|
+
`]], probe: ["launchctl", ["print", `${domain}/${name}`]], install: [["launchctl", ["bootstrap", domain, file]], ["launchctl", ["enable", `${domain}/${name}`]]], uninstall: [["launchctl", ["bootout", `${domain}/${name}`]]] };
|
|
110
|
+
}
|
|
111
|
+
if (platform === "linux") {
|
|
112
|
+
const root = join(configHome, "systemd", "user");
|
|
113
|
+
return { backend: "systemd", name, files: [
|
|
114
|
+
[join(root, `${name}.service`), `[Unit]\nDescription=UsageMax incremental usage sync\n[Service]\nType=oneshot\nExecStart=${command.map(unitQuote).join(" ")}\nWorkingDirectory=${unitQuote(home)}\nNice=10\nIOSchedulingClass=idle\nTimeoutStartSec=infinity\nStandardOutput=null\nStandardError=null\n`],
|
|
115
|
+
[join(root, `${name}.timer`), `[Unit]\nDescription=UsageMax automatic sync\n[Timer]\nOnActiveSec=1m\nOnUnitInactiveSec=${minutes}m\nRandomizedDelaySec=60\nAccuracySec=30s\n[Install]\nWantedBy=timers.target\n`],
|
|
116
|
+
], probe: ["systemctl", ["--user", "is-active", `${name}.timer`]], install: [["systemctl", ["--user", "daemon-reload"]], ["systemctl", ["--user", "enable", "--now", `${name}.timer`]]], uninstall: [["systemctl", ["--user", "disable", "--now", `${name}.timer`]]] };
|
|
117
|
+
}
|
|
118
|
+
if (platform === "win32") {
|
|
119
|
+
const file = join(directory, "service-task.xml");
|
|
120
|
+
return { backend: "task-scheduler", name, files: [[file, `<?xml version="1.0" encoding="UTF-8"?>
|
|
121
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
122
|
+
<Triggers><TimeTrigger><Repetition><Interval>PT${minutes}M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><StartBoundary>${new Date(now + 60000).toISOString()}</StartBoundary><Enabled>true</Enabled><RandomDelay>PT1M</RandomDelay></TimeTrigger></Triggers>
|
|
123
|
+
<Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
124
|
+
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><StartWhenAvailable>true</StartWhenAvailable><Enabled>true</Enabled><WakeToRun>false</WakeToRun><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>7</Priority></Settings>
|
|
125
|
+
<Actions Context="Author"><Exec><Command>${xml(executable)}</Command><Arguments>${xml(args.map(windowsQuote).join(" "))}</Arguments><WorkingDirectory>${xml(home)}</WorkingDirectory></Exec></Actions></Task>
|
|
126
|
+
`]], probe: ["schtasks.exe", ["/Query", "/TN", name, "/XML"]], install: [["schtasks.exe", ["/Create", "/TN", name, "/XML", file, "/F"]]], uninstall: [["schtasks.exe", ["/Delete", "/TN", name, "/F"]]] };
|
|
127
|
+
}
|
|
128
|
+
throw new Error("Automatic sync supports macOS, Windows, and Linux with a user systemd manager (including running WSL distributions).");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readJson(path) {
|
|
132
|
+
try { return JSON.parse(await readFile(path, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function atomicWrite(path, content) {
|
|
136
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
137
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
138
|
+
try {
|
|
139
|
+
await writeFile(temp, content, { mode: 0o600, flag: "wx" });
|
|
140
|
+
await rename(temp, path);
|
|
141
|
+
} finally { await unlink(temp).catch(() => {}); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runCommand = ([command, args]) => execute(command, args, { timeout: 15000, maxBuffer: 256 * 1024, windowsHide: true });
|
|
145
|
+
|
|
146
|
+
export async function manageService(action, { directory, cli, minutes, env = process.env, executeCommand = runCommand, home = homedir(), platform = process.platform }) {
|
|
147
|
+
directory = resolve(directory);
|
|
148
|
+
const settingsPath = join(directory, "service.json");
|
|
149
|
+
const settings = await readJson(settingsPath);
|
|
150
|
+
const configHome = settings?.configHome || env.XDG_CONFIG_HOME || join(home, ".config");
|
|
151
|
+
const executable = brandedRuntimePath(directory, platform) || process.execPath;
|
|
152
|
+
const plan = servicePlan({ directory, cli, executable, minutes: minutes ?? settings?.minutes ?? 15, configHome, home, platform });
|
|
153
|
+
if (action === "status") {
|
|
154
|
+
let registered = false;
|
|
155
|
+
try { await executeCommand(plan.probe); registered = true; } catch { /* Not installed, inactive, or unavailable. */ }
|
|
156
|
+
return { installed: Boolean(settings), schedulerReachable: registered, backend: plan.backend, minutes: settings?.minutes ?? null, lastRun: await readJson(join(directory, "service-state.json")), hint: registered ? "Check lastRun for sync health. Windows task registration does not prove it is enabled." : "Scheduler is not active/reachable. On WSL, start the distro and enable its systemd user manager." };
|
|
157
|
+
}
|
|
158
|
+
if (action === "uninstall") {
|
|
159
|
+
if (!settings) return { installed: false };
|
|
160
|
+
// Failure is surfaced: do not claim removal while a scheduler might still run.
|
|
161
|
+
for (const command of plan.uninstall) await executeCommand(command);
|
|
162
|
+
for (const [file] of plan.files) await unlink(file).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
163
|
+
await unlink(settingsPath);
|
|
164
|
+
if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "daemon-reload"]]);
|
|
165
|
+
return { installed: false, retained: "Account link, usage checkpoints, and last-run status retained. An in-flight sync may finish." };
|
|
166
|
+
}
|
|
167
|
+
if (action !== "install") throw new Error("Use service install [--every 15], status, run, or uninstall.");
|
|
168
|
+
assertDurablePath(await realpath(cli));
|
|
169
|
+
if (platform === "darwin" || platform === "win32") assertDurablePath(await realpath(process.execPath));
|
|
170
|
+
await access(join(directory, "config.json"));
|
|
171
|
+
if (platform === "darwin" || platform === "win32") await ensureBrandedRuntime(directory, process.execPath, platform);
|
|
172
|
+
// Check the user manager before writing anything. WSL without systemd fails clearly.
|
|
173
|
+
if (plan.backend === "systemd") await executeCommand(["systemctl", ["--user", "show-environment"]]);
|
|
174
|
+
if (settings) {
|
|
175
|
+
let registered = false;
|
|
176
|
+
try { await executeCommand(plan.probe); registered = true; } catch { /* Missing registration can be repaired. */ }
|
|
177
|
+
if (registered) for (const command of plan.uninstall) await executeCommand(command);
|
|
178
|
+
}
|
|
179
|
+
const next = { version: 1, minutes: intervalMinutes(minutes ?? settings?.minutes ?? 15), configHome, env: capturedEnvironment(env), installedAt: new Date().toISOString() };
|
|
180
|
+
await atomicWrite(settingsPath, JSON.stringify(next));
|
|
181
|
+
for (const [file, content] of plan.files) await atomicWrite(file, content);
|
|
182
|
+
try {
|
|
183
|
+
for (const command of plan.install) await executeCommand(command);
|
|
184
|
+
await executeCommand(plan.probe);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new Error("Scheduler registration failed. Settings were retained for repair; rerun service install or check service status.", { cause: error });
|
|
187
|
+
}
|
|
188
|
+
return { installed: true, backend: plan.backend, minutes: next.minutes, note: "Opt-in one-shot jobs; no daemon, auto-updater, or wake-from-sleep. Reinstall after moving/upgrading the CLI or changing source paths." };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function runScheduledSync(directory, action, { now = () => Date.now(), env = process.env } = {}) {
|
|
192
|
+
const settings = await readJson(join(directory, "service.json"));
|
|
193
|
+
if (!settings) return { status: "disabled" };
|
|
194
|
+
const path = join(directory, "service-state.json");
|
|
195
|
+
const previous = await readJson(path) || {};
|
|
196
|
+
if (previous.nextAttemptAt > now()) return { status: "backoff", nextAttemptAt: previous.nextAttemptAt };
|
|
197
|
+
Object.assign(env, capturedEnvironment(settings.env || {}));
|
|
198
|
+
const start = now();
|
|
199
|
+
let state;
|
|
200
|
+
try {
|
|
201
|
+
await action();
|
|
202
|
+
state = { status: "ok", failures: 0, lastSuccessAt: new Date(now()).toISOString() };
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error.code === "USAGEMAX_BUSY") return { status: "busy" };
|
|
205
|
+
const failures = Math.min((previous.failures || 0) + 1, 10);
|
|
206
|
+
// Save no command output, tokens, paths, or raw server error bodies.
|
|
207
|
+
state = { status: "error", failures, lastSuccessAt: previous.lastSuccessAt ?? null, nextAttemptAt: now() + Math.min(6 * 60, settings.minutes * 2 ** failures) * 60000, hint: "Run usagemax sync manually to diagnose; checkpoints are preserved." };
|
|
208
|
+
}
|
|
209
|
+
state = { ...state, attemptedAt: new Date(start).toISOString(), durationMs: now() - start };
|
|
210
|
+
await atomicWrite(path, JSON.stringify(state));
|
|
211
|
+
return state;
|
|
212
|
+
}
|
package/src/transport.js
CHANGED
|
@@ -7,6 +7,82 @@ export function retryAfterMs(value, now = Date.now()) {
|
|
|
7
7
|
return Number.isFinite(date) ? Math.max(0, date - now) : 0;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
function record(value) {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const collectorStates = new Set(["active", "revoked", "workspace_disabled", "membership_inactive", "device_mismatch", "scope_missing"]);
|
|
15
|
+
const deviceBindings = new Set(["unbound", "bound", "matched", "mismatch"]);
|
|
16
|
+
const collectorTokenPattern = /umx_[a-f0-9]{64}/gi;
|
|
17
|
+
|
|
18
|
+
function safeText(value, secret) {
|
|
19
|
+
let text = value;
|
|
20
|
+
if (secret) text = text.split(secret).join("[redacted]");
|
|
21
|
+
return text.replace(collectorTokenPattern, "[redacted]").slice(0, 160);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Only copy the documented diagnostic projection. This keeps a compromised or
|
|
25
|
+
// misconfigured endpoint from echoing a collector secret through the CLI.
|
|
26
|
+
export function collectorStatusView(httpStatus, value, secret) {
|
|
27
|
+
const body = record(value);
|
|
28
|
+
const view = { tokenFormat: "valid", httpStatus };
|
|
29
|
+
if (!body) {
|
|
30
|
+
return {
|
|
31
|
+
...view,
|
|
32
|
+
status: httpStatus === 401 ? "rejected" : "unavailable",
|
|
33
|
+
reason: httpStatus === 401
|
|
34
|
+
? "UsageMax did not accept this collector token. It may be unknown, revoked, disabled, or from another deployment."
|
|
35
|
+
: "UsageMax returned no machine-readable collector status.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof body.status === "string" && collectorStates.has(body.status)) view.status = body.status;
|
|
40
|
+
if (body.credentialType === "collector") view.credentialType = body.credentialType;
|
|
41
|
+
if (body.writeOnly === true) view.writeOnly = true;
|
|
42
|
+
if (body.activation === "not_required") view.activation = body.activation;
|
|
43
|
+
if (body.expiresAt === null) view.expiresAt = null;
|
|
44
|
+
if (Array.isArray(body.scopes)) view.scopes = body.scopes.filter((scope) => typeof scope === "string").slice(0, 16);
|
|
45
|
+
if (body.scopeStatus === "valid" || body.scopeStatus === "missing_telemetry_write") view.scopeStatus = body.scopeStatus;
|
|
46
|
+
if (typeof body.ingestAuthorized === "boolean") view.ingestAuthorized = body.ingestAuthorized;
|
|
47
|
+
if (typeof body.deviceBinding === "string" && deviceBindings.has(body.deviceBinding)) view.deviceBinding = body.deviceBinding;
|
|
48
|
+
for (const key of ["profileHandle", "deviceName", "platform", "cliVersion", "lastFailureCode"]) {
|
|
49
|
+
if (typeof body[key] === "string") view[key] = safeText(body[key], secret);
|
|
50
|
+
}
|
|
51
|
+
for (const key of ["createdAt", "lastSeenAt", "lastSuccessAt", "lastFailureAt"]) {
|
|
52
|
+
if (typeof body[key] === "number" && Number.isSafeInteger(body[key])) view[key] = body[key];
|
|
53
|
+
else if (body[key] === null) view[key] = null;
|
|
54
|
+
}
|
|
55
|
+
if (!view.status) {
|
|
56
|
+
view.status = httpStatus === 401 ? "rejected" : "unavailable";
|
|
57
|
+
view.reason = httpStatus === 401
|
|
58
|
+
? "UsageMax did not accept this collector token. It may be unknown, revoked, disabled, or from another deployment."
|
|
59
|
+
: "UsageMax returned an incomplete collector status.";
|
|
60
|
+
}
|
|
61
|
+
return view;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function requestCollectorStatus(endpoint, config, {
|
|
65
|
+
timeout = 15_000,
|
|
66
|
+
fetchImpl = fetch,
|
|
67
|
+
} = {}) {
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await fetchImpl(endpoint, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: {
|
|
73
|
+
authorization: `Bearer ${config.token}`,
|
|
74
|
+
...(config.deviceId ? { "x-usagemax-device-id": config.deviceId } : {}),
|
|
75
|
+
},
|
|
76
|
+
cache: "no-store",
|
|
77
|
+
signal: AbortSignal.timeout(timeout),
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
throw new Error("Collector status could not reach UsageMax or timed out. Check your network connection.");
|
|
81
|
+
}
|
|
82
|
+
const body = await response.json().catch(() => null);
|
|
83
|
+
return { httpStatus: response.status, body };
|
|
84
|
+
}
|
|
85
|
+
|
|
10
86
|
// Only snapshot operations have server receipts. Never automatically replay a
|
|
11
87
|
// one-use link request or apply this policy to arbitrary POST operations.
|
|
12
88
|
export async function requestSnapshot(endpoint, config, operation, payload, {
|