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.
- package/dist/api/ingest-client.js +26 -0
- package/dist/cli.js +1 -0
- package/dist/commands/sync.js +132 -10
- package/package.json +1 -1
|
@@ -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
|
});
|
package/dist/commands/sync.js
CHANGED
|
@@ -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-
|
|
92
|
+
upload.warn("token rejected; re-authorizing and re-uploading full history");
|
|
92
93
|
await deleteConfig();
|
|
93
94
|
const freshToken = await runDeviceFlow(base);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|