claudeusage-sync 0.2.0 → 0.3.0

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.
@@ -9,6 +9,11 @@ const ingestResponseSchema = z.object({
9
9
  updatedAt: z.string(),
10
10
  userId: z.string(),
11
11
  });
12
+ const coverageResponseSchema = z.object({
13
+ accountWindowEnd: z.string().nullable(),
14
+ hasData: z.boolean(),
15
+ machineWindowEnd: z.string().nullable(),
16
+ });
12
17
  export class NeedsReauth extends Error {
13
18
  constructor() {
14
19
  super("needs_reauth");
@@ -60,3 +65,24 @@ export async function postIngest(apiBase, token, payload) {
60
65
  }
61
66
  return ingestResponseSchema.parse(await response.json());
62
67
  }
68
+ // Read-only probe of what the server already has for this account/machine, used to
69
+ // reconcile a possibly-stale local watermark before declaring "nothing to sync".
70
+ // Reuses the NeedsReauth / RateLimited contract so callers funnel a rejected token
71
+ // into the same re-auth path as ingest.
72
+ export async function getCoverage(apiBase, token, machineId) {
73
+ const response = await fetch(`${apiBase}/api/coverage?machineId=${encodeURIComponent(machineId)}`, {
74
+ headers: { authorization: `Bearer ${token}` },
75
+ method: "GET",
76
+ });
77
+ if (response.status === 401) {
78
+ throw new NeedsReauth();
79
+ }
80
+ if (response.status === 429) {
81
+ const retryAfterSec = Number(response.headers.get("retry-after") ?? "60");
82
+ throw new RateLimited(Number.isFinite(retryAfterSec) ? retryAfterSec : 60);
83
+ }
84
+ if (!response.ok) {
85
+ throw new Error(`coverage failed: ${response.status} ${await response.text().catch(() => "")}`);
86
+ }
87
+ return coverageResponseSchema.parse(await response.json());
88
+ }
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@ program
18
18
  .option("--token <token>", "use this sync token instead of the browser device flow (headless/CI)")
19
19
  .option("--dry-run", "parse and build the payload but do not upload")
20
20
  .option("--since <date>", "only read records newer than this YYYY-MM-DD")
21
+ .option("--full", "ignore the local watermark and re-upload your entire history (use after deleting + recreating your account)")
21
22
  .action((options) => {
22
23
  runSync(options).catch(handleError);
23
24
  });
@@ -1,14 +1,15 @@
1
1
  import chalk from "chalk";
2
2
  import ora from "ora";
3
3
  import { buildPayload, latestWatermark } from "../aggregate/payload.js";
4
- import { IngestConflict, NeedsReauth, postIngest, RateLimited, } from "../api/ingest-client.js";
4
+ import { getCoverage, IngestConflict, NeedsReauth, postIngest, RateLimited, } from "../api/ingest-client.js";
5
+ import { computeMachineId } from "../auth/machine.js";
5
6
  import { deleteConfig, readConfig, writeConfig, } from "../auth/config.js";
6
7
  import { runDeviceFlow } from "../auth/device-flow.js";
7
8
  import { readPackageInfo } from "../package.js";
8
9
  import { dedupe, detectOutputTokenUndercount, recordWatermarkKey, } from "../parse/dedupe.js";
9
10
  import { streamAllRecords } from "../parse/jsonl.js";
10
11
  import { resolveClaudeDir } from "../parse/paths.js";
11
- const DEFAULT_API = "https://claudeusage.com";
12
+ const DEFAULT_API = "https://www.claudeusage.com";
12
13
  const packageInfo = readPackageInfo(import.meta.url);
13
14
  function apiBase() {
14
15
  return (process.env.CLAUDEUSAGE_API ?? DEFAULT_API).replace(/\/$/, "");
@@ -77,7 +78,7 @@ function printDryRun(payload, records) {
77
78
  console.log(" cacheCreate: ", totals.cacheCreate);
78
79
  console.log(" cacheRead: ", totals.cacheRead);
79
80
  }
80
- async function uploadWithReauth(base, token, payload) {
81
+ async function uploadWithReauth(base, token, payload, allRecords, version) {
81
82
  const upload = ora(`uploading ${payload.dailyBuckets.length} day-buckets...`).start();
82
83
  try {
83
84
  const result = await postIngest(base, token, payload);
@@ -88,13 +89,32 @@ async function uploadWithReauth(base, token, payload) {
88
89
  }
89
90
  catch (error) {
90
91
  if (error instanceof NeedsReauth) {
91
- upload.warn("token rejected; re-running device authorization");
92
+ upload.warn("token rejected; re-authorizing and re-uploading full history");
92
93
  await deleteConfig();
93
94
  const freshToken = await runDeviceFlow(base);
94
- const result = await postIngest(base, freshToken, payload);
95
- console.log(chalk.green(result.duplicate
96
- ? "already synced after re-auth; duplicate upload ignored"
97
- : `synced after re-auth (${result.newRecords} day-buckets, ${result.messageCount} messages)`));
95
+ // A rejected token means the account was reset/recreated, so it holds no
96
+ // prior data. Send the FULL history — not just the window after the old
97
+ // watermark, which would drop everything before the last sync. If the
98
+ // account actually still has data (e.g. only the token was revoked), the
99
+ // server rejects the overlap and we fall back to the incremental payload.
100
+ const fullPayload = buildPayload(dedupe(allRecords), version);
101
+ try {
102
+ const result = await postIngest(base, freshToken, fullPayload);
103
+ console.log(chalk.green(result.duplicate
104
+ ? "already synced after re-auth; duplicate upload ignored"
105
+ : `synced full history after re-auth (${result.newRecords} day-buckets, ${result.messageCount} messages)`));
106
+ }
107
+ catch (reError) {
108
+ if (reError instanceof IngestConflict && reError.code === "ingest_overlap") {
109
+ const result = await postIngest(base, freshToken, payload);
110
+ console.log(chalk.green(result.duplicate
111
+ ? "already synced after re-auth; duplicate upload ignored"
112
+ : `synced after re-auth (${result.newRecords} day-buckets, ${result.messageCount} messages)`));
113
+ }
114
+ else {
115
+ throw reError;
116
+ }
117
+ }
98
118
  return freshToken;
99
119
  }
100
120
  if (error instanceof RateLimited) {
@@ -132,7 +152,11 @@ export async function runSync(options) {
132
152
  const cutoff = parseSince(options.since);
133
153
  filtered = allRecords.filter((record) => new Date(record.timestamp).getTime() >= cutoff);
134
154
  }
135
- else if (config) {
155
+ else if (config && !options.full) {
156
+ // --full ignores the local watermark and re-sends the entire history. This
157
+ // is the recovery path after deleting + recreating an account: an ordinary
158
+ // incremental sync would only upload the window after the old watermark
159
+ // (often just the last day) to the brand-new, empty account.
136
160
  filtered = allRecords.filter((record) => afterWatermark(record, config));
137
161
  }
138
162
  const deduped = dedupe(filtered);
@@ -146,6 +170,16 @@ export async function runSync(options) {
146
170
  return;
147
171
  }
148
172
  if (payload.dailyBuckets.length === 0) {
173
+ // The local watermark filtered everything out — but it may be STALE (e.g. the
174
+ // account was deleted + recreated, so the server actually has nothing). Before
175
+ // declaring "nothing to do", reconcile against the server's real coverage so a
176
+ // plain `claudeusage-sync` self-heals and restores the full history with no
177
+ // flag. Skipped for headless --token runs (must never pop a browser) and the
178
+ // explicit --since / --full paths, which already choose the window themselves.
179
+ if (token && config && !options.full && !options.since && !options.token) {
180
+ await reconcileEmptyPayload(base, token, config, allRecords);
181
+ return;
182
+ }
149
183
  console.log(chalk.yellow("no new Claude Code usage records to sync."));
150
184
  if (config) {
151
185
  await writeConfig(config);
@@ -163,7 +197,7 @@ export async function runSync(options) {
163
197
  writableConfig.consentAcceptedAt = new Date().toISOString();
164
198
  }
165
199
  await writeConfig(writableConfig);
166
- const finalToken = await uploadWithReauth(base, token, payload);
200
+ const finalToken = await uploadWithReauth(base, token, payload, allRecords, packageInfo.version);
167
201
  const watermark = latestWatermark(deduped);
168
202
  await writeConfig({
169
203
  ...writableConfig,
@@ -173,3 +207,91 @@ export async function runSync(options) {
173
207
  });
174
208
  console.log(chalk.bold("\nprofile:"), chalk.hex("#d97757")(`${base}/dashboard`));
175
209
  }
210
+ // Reached when the local watermark says there is nothing new to upload. The
211
+ // watermark can be STALE — most importantly after the account was deleted and
212
+ // recreated, where the server now holds nothing but the old watermark still hides
213
+ // the entire history. We probe the server's real coverage and, if it is behind,
214
+ // re-upload exactly the missing window (the full history for a fresh account),
215
+ // anchored on the per-machine ingest frontier so the overlap guard never trips.
216
+ async function reconcileEmptyPayload(base, token, config, allRecords) {
217
+ const machineId = computeMachineId();
218
+ let activeToken = token;
219
+ const probe = ora("checking the server for your synced history...").start();
220
+ let coverage;
221
+ try {
222
+ coverage = await getCoverage(base, activeToken, machineId);
223
+ probe.stop();
224
+ }
225
+ catch (error) {
226
+ if (error instanceof NeedsReauth) {
227
+ // Token rejected: the account was deleted/recreated, or the token was
228
+ // revoked. Re-authorize, then RE-PROBE with the fresh token so we upload
229
+ // only what the (possibly brand-new) account is missing — never a blind
230
+ // full re-upload, which would double-count an account that still has data.
231
+ probe.warn("sync token no longer valid; re-authorizing");
232
+ await deleteConfig();
233
+ activeToken = await runDeviceFlow(base);
234
+ coverage = await getCoverage(base, activeToken, machineId);
235
+ }
236
+ else if (error instanceof RateLimited) {
237
+ probe.info("server busy; skipping the history check this run");
238
+ await persistNoop(base, config, activeToken, allRecords);
239
+ return;
240
+ }
241
+ else {
242
+ // 404 (older server without this endpoint) / 5xx / network — never break a
243
+ // routine no-op sync over a failed reconcile.
244
+ probe.warn("could not verify server coverage; skipping the history check");
245
+ await persistNoop(base, config, activeToken, allRecords);
246
+ return;
247
+ }
248
+ }
249
+ const frontier = coverage.machineWindowEnd;
250
+ let resyncRecords;
251
+ if (!coverage.hasData) {
252
+ // Empty account (the fresh-after-delete case) → send the entire history.
253
+ resyncRecords = frontier
254
+ ? allRecords.filter((record) => record.timestamp > frontier)
255
+ : allRecords;
256
+ }
257
+ else if (!frontier) {
258
+ // Account already holds data but this machine has no ingest frontier (e.g.
259
+ // the data came from another machine). Uploading could double-count, so no-op.
260
+ await persistNoop(base, config, activeToken, allRecords);
261
+ return;
262
+ }
263
+ else {
264
+ resyncRecords = allRecords.filter((record) => record.timestamp > frontier);
265
+ }
266
+ const resyncDeduped = dedupe(resyncRecords);
267
+ const resyncPayload = buildPayload(resyncDeduped, packageInfo.version);
268
+ if (resyncPayload.dailyBuckets.length === 0) {
269
+ // Server is already caught up with this machine — a genuine no-op.
270
+ await persistNoop(base, config, activeToken, allRecords);
271
+ return;
272
+ }
273
+ console.log(chalk.cyan(`server is missing history — restoring ${resyncPayload.dailyBuckets.length} day-buckets.`));
274
+ const writableConfig = { ...config, apiBase: base, token: activeToken };
275
+ if (!writableConfig.consentAcceptedAt) {
276
+ printUploadNotice(resyncPayload.dailyBuckets.length, resyncPayload.sessionCount);
277
+ writableConfig.consentAcceptedAt = new Date().toISOString();
278
+ }
279
+ await writeConfig(writableConfig);
280
+ const finalToken = await uploadWithReauth(base, activeToken, resyncPayload, allRecords, packageInfo.version);
281
+ const watermark = latestWatermark(resyncDeduped);
282
+ await writeConfig({
283
+ ...writableConfig,
284
+ ...watermark,
285
+ apiBase: base,
286
+ token: finalToken,
287
+ });
288
+ console.log(chalk.bold("\nprofile:"), chalk.hex("#d97757")(`${base}/dashboard`));
289
+ }
290
+ // Genuine "nothing to do": print the message and persist config with the latest
291
+ // local record as the watermark (and any refreshed token), so the next run filters
292
+ // from the right place even if we just re-authorized.
293
+ async function persistNoop(base, config, token, allRecords) {
294
+ console.log(chalk.yellow("no new Claude Code usage records to sync."));
295
+ const watermark = latestWatermark(dedupe(allRecords));
296
+ await writeConfig({ ...config, ...watermark, apiBase: base, token });
297
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeusage-sync",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sync your Claude Code usage stats to claudeusage.com",
5
5
  "keywords": [
6
6
  "claude",