usagemax 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,5 +38,6 @@ UsageMax uses [ccusage](https://github.com/ccusage/ccusage) for local source det
38
38
  - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
39
39
  - After the first import, normal syncs inspect only the current and previous local day; use `sync --full` to reconcile older history.
40
40
  - The collector key is written with user-only permissions where the operating system supports them.
41
+ - A separate private random installation ID survives collector rotation, relinking, and display-name changes. It is not a hardware fingerprint; the server stores only its SHA-256 hash. Concurrent or repeated syncs remain idempotent.
41
42
 
42
43
  Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development/self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usagemax",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Link local coding-agent usage to your UsageMax profile",
5
5
  "license": "MIT",
6
6
  "author": "UsageMax",
@@ -15,11 +15,12 @@
15
15
  },
16
16
  "type": "module",
17
17
  "bin": {
18
- "usagemax": "./src/cli.js"
18
+ "usagemax": "src/cli.js"
19
19
  },
20
20
  "files": [
21
21
  "src/cli.js",
22
22
  "src/core.js",
23
+ "src/installation.js",
23
24
  "README.md",
24
25
  "LICENSE"
25
26
  ],
package/src/cli.js CHANGED
@@ -10,10 +10,11 @@ import process from "node:process";
10
10
  import { promisify } from "node:util";
11
11
 
12
12
  import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
13
+ import { stableInstallationId } from "./installation.js";
13
14
 
14
15
  const require = createRequire(import.meta.url);
15
16
  const executeFile = promisify(execFile);
16
- const VERSION = "0.1.0";
17
+ const VERSION = "0.1.1";
17
18
  const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
18
19
  const CONFIG_FILE = "config.json";
19
20
  const MAX_REPORT_BYTES = 100 * 1024 * 1024;
@@ -184,26 +185,32 @@ async function link(args) {
184
185
  const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
185
186
  if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
186
187
  const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
188
+ const previous = await readConfig();
189
+ const deviceId = await stableInstallationId(configDirectory(), previous?.deviceId);
190
+ const headers = { "content-type": "application/json" };
191
+ if (previous?.token) headers.authorization = `Bearer ${previous.token}`;
187
192
  const response = await fetch(endpoint, {
188
193
  method: "POST",
189
- headers: { "content-type": "application/json" },
190
- body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION }),
194
+ headers,
195
+ body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION, deviceId }),
191
196
  signal: AbortSignal.timeout(15_000),
192
197
  });
193
198
  const body = await response.json().catch(() => ({}));
194
199
  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.");
195
200
  const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
196
201
  if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
202
+ const profileHandle = typeof body.profileHandle === "string" ? body.profileHandle : undefined;
203
+ const sameAccount = Boolean(previous && previous.profileHandle && previous.profileHandle === profileHandle);
197
204
  const config = {
198
205
  version: 1,
199
206
  token: body.token,
200
207
  ingestUrl,
201
208
  profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
202
- profileHandle: typeof body.profileHandle === "string" ? body.profileHandle : undefined,
203
- deviceId: randomUUID(),
209
+ profileHandle,
210
+ deviceId,
204
211
  deviceName: name,
205
212
  linkedAt: new Date().toISOString(),
206
- snapshots: {},
213
+ snapshots: sameAccount ? previous.snapshots : {},
207
214
  };
208
215
  await writeConfig(config);
209
216
  process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
@@ -218,6 +225,7 @@ async function link(args) {
218
225
  async function sync(args, suppliedConfig) {
219
226
  const config = suppliedConfig || await readConfig();
220
227
  if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
228
+ config.deviceId = await stableInstallationId(configDirectory(), config.deviceId);
221
229
  const full = args.includes("--full");
222
230
  const inventory = await sourceInventory();
223
231
  const today = new Date().toISOString().slice(0, 10);
@@ -239,6 +247,7 @@ async function sync(args, suppliedConfig) {
239
247
  authorization: `Bearer ${config.token}`,
240
248
  "content-type": "application/json",
241
249
  "idempotency-key": batchId(config.deviceId, events),
250
+ "x-usagemax-device-id": config.deviceId,
242
251
  },
243
252
  body: JSON.stringify({ events }),
244
253
  signal: AbortSignal.timeout(30_000),
@@ -272,6 +281,11 @@ async function status() {
272
281
  process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
273
282
  return;
274
283
  }
284
+ const stableId = await stableInstallationId(configDirectory(), config.deviceId);
285
+ if (config.deviceId !== stableId) {
286
+ config.deviceId = stableId;
287
+ await writeConfig(config);
288
+ }
275
289
  process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
276
290
  process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
277
291
  process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
@@ -307,7 +321,7 @@ async function removeLink() {
307
321
  return;
308
322
  }
309
323
  await unlink(path);
310
- process.stdout.write("Removed the local UsageMax collector key. Revoke the collector in your account if this computer is no longer trusted.\n");
324
+ process.stdout.write("Removed the local UsageMax collector key. This computer's private installation identity was retained so relinking cannot duplicate its usage. Revoke the collector in your account if this computer is no longer trusted.\n");
311
325
  }
312
326
 
313
327
  async function main() {
@@ -0,0 +1,38 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { platform } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const INSTALLATION_FILE = "installation.json";
7
+
8
+ export function validInstallationId(value) {
9
+ return typeof value === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
10
+ }
11
+
12
+ export async function stableInstallationId(directory, preferred) {
13
+ const path = join(directory, INSTALLATION_FILE);
14
+ try {
15
+ const parsed = JSON.parse(await readFile(path, "utf8"));
16
+ if (validInstallationId(parsed?.id)) return parsed.id;
17
+ throw new Error(`UsageMax installation identity is invalid: ${path}`);
18
+ } catch (error) {
19
+ if (error?.code !== "ENOENT") throw error;
20
+ }
21
+
22
+ const id = validInstallationId(preferred) ? preferred : randomUUID();
23
+ await mkdir(directory, { recursive: true, mode: 0o700 });
24
+ try {
25
+ await writeFile(path, `${JSON.stringify({ version: 1, id }, null, 2)}\n`, {
26
+ encoding: "utf8",
27
+ mode: 0o600,
28
+ flag: "wx",
29
+ });
30
+ if (platform() !== "win32") await chmod(path, 0o600);
31
+ return id;
32
+ } catch (error) {
33
+ if (error?.code !== "EEXIST") throw error;
34
+ const parsed = JSON.parse(await readFile(path, "utf8"));
35
+ if (validInstallationId(parsed?.id)) return parsed.id;
36
+ throw new Error(`UsageMax installation identity is invalid: ${path}`);
37
+ }
38
+ }