@wspc/cli 0.0.15 → 0.0.17

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/cli.js CHANGED
@@ -1177,10 +1177,21 @@ var V1_CRED_KEYS = [
1177
1177
  "actor",
1178
1178
  "agent_label"
1179
1179
  ];
1180
+ var CONSISTENCY_BOOKMARK_SERVICES = ["auth", "todo", "calendar", "email", "push"];
1181
+ function normalizeConsistencyBookmarks(raw) {
1182
+ if (typeof raw !== "object" || raw === null) return void 0;
1183
+ const out = {};
1184
+ const obj = raw;
1185
+ for (const service of CONSISTENCY_BOOKMARK_SERVICES) {
1186
+ if (typeof obj[service] === "string") out[service] = obj[service];
1187
+ }
1188
+ return Object.keys(out).length ? out : void 0;
1189
+ }
1180
1190
  function migrateEnv(raw) {
1181
1191
  const api_base = typeof raw.api_base === "string" ? raw.api_base : "";
1182
1192
  const env = { api_base, accounts: {} };
1183
- if (typeof raw.consistency_bookmark === "string") env.consistency_bookmark = raw.consistency_bookmark;
1193
+ const consistency_bookmarks = normalizeConsistencyBookmarks(raw.consistency_bookmarks);
1194
+ if (consistency_bookmarks) env.consistency_bookmarks = consistency_bookmarks;
1184
1195
  if (typeof raw.client_id === "string") env.client_id = raw.client_id;
1185
1196
  if (raw.accounts && typeof raw.accounts === "object") {
1186
1197
  env.accounts = raw.accounts;
@@ -1248,6 +1259,49 @@ var ConfigStore = class {
1248
1259
  }
1249
1260
  await fs.writeFile(this.configFile, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
1250
1261
  }
1262
+ /**
1263
+ * Read-modify-write the config under a cross-process file lock. The mutator
1264
+ * runs against a FRESH read taken inside the lock and edits it in place, so
1265
+ * two writers (e.g. token refresh and the consistency-bookmark writeback)
1266
+ * can't clobber each other from stale snapshots — the bug that revoked whole
1267
+ * refresh-token families when several CLI sessions ran concurrently.
1268
+ */
1269
+ async update(mutate) {
1270
+ await this.withLock(async () => {
1271
+ const config = await this.read();
1272
+ mutate(config);
1273
+ await this.write(config);
1274
+ });
1275
+ }
1276
+ async withLock(fn) {
1277
+ await fs.mkdir(this.configDir, { recursive: true, mode: 448 });
1278
+ const lockFile = this.configFile + ".lock";
1279
+ const STALE_MS = 1e4;
1280
+ const RETRY_MS = 25;
1281
+ const MAX_WAIT_MS = 5e3;
1282
+ let waited = 0;
1283
+ for (; ; ) {
1284
+ try {
1285
+ const fh = await fs.open(lockFile, "wx");
1286
+ await fh.close();
1287
+ break;
1288
+ } catch (e) {
1289
+ if (e.code !== "EEXIST") throw e;
1290
+ const age = await fs.stat(lockFile).then((s) => Date.now() - s.mtimeMs).catch(() => Infinity);
1291
+ if (age > STALE_MS || waited >= MAX_WAIT_MS) {
1292
+ await fs.rm(lockFile, { force: true });
1293
+ continue;
1294
+ }
1295
+ await new Promise((r) => setTimeout(r, RETRY_MS));
1296
+ waited += RETRY_MS;
1297
+ }
1298
+ }
1299
+ try {
1300
+ return await fn();
1301
+ } finally {
1302
+ await fs.rm(lockFile, { force: true });
1303
+ }
1304
+ }
1251
1305
  async currentEnv() {
1252
1306
  const c = await this.read();
1253
1307
  const name = c.current_env;
@@ -1259,8 +1313,22 @@ var ConfigStore = class {
1259
1313
  };
1260
1314
 
1261
1315
  // src/handwritten/auth/consistency-fetch.ts
1262
- var HEADER = "x-consistency-bookmark";
1263
1316
  var INVALID_BOOKMARK = "INVALID_CONSISTENCY_BOOKMARK";
1317
+ var SERVICE_HEADERS = {
1318
+ auth: "x-cb-auth",
1319
+ todo: "x-cb-todo",
1320
+ calendar: "x-cb-cal",
1321
+ email: "x-cb-email",
1322
+ push: "x-cb-push"
1323
+ };
1324
+ var SERVICE_PREFIXES = [
1325
+ { service: "auth", prefix: "/auth" },
1326
+ { service: "todo", prefix: "/todo" },
1327
+ { service: "calendar", prefix: "/calendar" },
1328
+ { service: "email", prefix: "/email" },
1329
+ { service: "push", prefix: "/push" }
1330
+ ];
1331
+ var KNOWN_HEADERS = Object.values(SERVICE_HEADERS);
1264
1332
  function normalizeBasePath(pathname) {
1265
1333
  const trimmed = pathname.replace(/\/+$/, "");
1266
1334
  return trimmed === "" ? "/" : trimmed;
@@ -1270,6 +1338,26 @@ function isUnderApiBase(url, apiBase) {
1270
1338
  const basePath = normalizeBasePath(base.pathname);
1271
1339
  return url.origin === base.origin && (basePath === "/" || url.pathname === basePath || url.pathname.startsWith(`${basePath}/`));
1272
1340
  }
1341
+ function pathWithinApiBase(url, apiBase) {
1342
+ const basePath = normalizeBasePath(new URL(apiBase).pathname);
1343
+ if (basePath === "/") return url.pathname;
1344
+ if (url.pathname === basePath) return "/";
1345
+ return url.pathname.slice(basePath.length) || "/";
1346
+ }
1347
+ function pathMatchesPrefix(pathname, prefix) {
1348
+ return pathname === prefix || pathname.startsWith(`${prefix}/`);
1349
+ }
1350
+ function serviceForPath(pathname) {
1351
+ return SERVICE_PREFIXES.find(({ prefix }) => pathMatchesPrefix(pathname, prefix))?.service;
1352
+ }
1353
+ function stripKnownBookmarkHeaders(request, keep) {
1354
+ if (!KNOWN_HEADERS.some((header) => header !== keep && request.headers.has(header))) return request;
1355
+ const headers = new Headers(request.headers);
1356
+ for (const header of KNOWN_HEADERS) {
1357
+ if (header !== keep) headers.delete(header);
1358
+ }
1359
+ return new Request(request, { headers });
1360
+ }
1273
1361
  function isJsonContentType(contentType) {
1274
1362
  const mediaType = contentType.toLowerCase().split(";")[0]?.trim() ?? "";
1275
1363
  return mediaType === "application/json" || mediaType.endsWith("+json");
@@ -1291,46 +1379,54 @@ function createConsistencyFetch(opts) {
1291
1379
  const url = new URL(request.url);
1292
1380
  const applies = isUnderApiBase(url, opts.apiBase);
1293
1381
  let outgoing = request;
1294
- let injectedStoredBookmark = false;
1295
- if (applies && !outgoing.headers.has(HEADER)) {
1296
- const config2 = await opts.store.read();
1297
- const bookmark = config2.envs[opts.envName]?.consistency_bookmark;
1298
- if (bookmark) {
1299
- const headers = new Headers(outgoing.headers);
1300
- headers.set(HEADER, bookmark);
1301
- outgoing = new Request(outgoing, { headers });
1302
- injectedStoredBookmark = true;
1382
+ let injectedService;
1383
+ const service = applies ? serviceForPath(pathWithinApiBase(url, opts.apiBase)) : void 0;
1384
+ const serviceHeader = service ? SERVICE_HEADERS[service] : void 0;
1385
+ outgoing = stripKnownBookmarkHeaders(outgoing, applies ? serviceHeader : void 0);
1386
+ if (applies && service) {
1387
+ const header = SERVICE_HEADERS[service];
1388
+ if (!outgoing.headers.has(header)) {
1389
+ const config = await opts.store.read();
1390
+ const bookmark = config.envs[opts.envName]?.consistency_bookmarks?.[service];
1391
+ if (bookmark) {
1392
+ const headers = new Headers(outgoing.headers);
1393
+ headers.set(header, bookmark);
1394
+ outgoing = new Request(outgoing, { headers });
1395
+ injectedService = service;
1396
+ }
1303
1397
  }
1304
1398
  }
1305
- if (!applies && outgoing.headers.has(HEADER)) {
1306
- const headers = new Headers(outgoing.headers);
1307
- headers.delete(HEADER);
1308
- outgoing = new Request(outgoing, { headers });
1309
- }
1310
1399
  const response = await fetchImpl(outgoing);
1311
1400
  if (!applies) return response;
1312
- const nextBookmark = response.headers.get(HEADER);
1313
- const shouldCheckInvalidBookmark = injectedStoredBookmark && !nextBookmark;
1401
+ const nextBookmarks = Object.entries(SERVICE_HEADERS).flatMap(([serviceName, header]) => {
1402
+ const value = response.headers.get(header);
1403
+ return value ? [[serviceName, value]] : [];
1404
+ });
1405
+ const shouldCheckInvalidBookmark = injectedService !== void 0;
1314
1406
  const invalidBookmark = shouldCheckInvalidBookmark ? await responseHasInvalidBookmark(response) : false;
1315
- const shouldClearBookmark = invalidBookmark;
1316
- if (!nextBookmark && !shouldClearBookmark) return response;
1317
- const config = await opts.store.read();
1318
- const env = config.envs[opts.envName];
1319
- if (!env) return response;
1320
- if (nextBookmark) {
1321
- env.consistency_bookmark = nextBookmark;
1322
- } else if (shouldClearBookmark) {
1323
- delete env.consistency_bookmark;
1324
- }
1325
- await opts.store.write(config);
1407
+ if (nextBookmarks.length === 0 && !invalidBookmark) return response;
1408
+ await opts.store.update((config) => {
1409
+ const env = config.envs[opts.envName];
1410
+ if (!env) return;
1411
+ env.consistency_bookmarks ??= {};
1412
+ for (const [serviceName, value] of nextBookmarks) {
1413
+ env.consistency_bookmarks[serviceName] = value;
1414
+ }
1415
+ if (invalidBookmark && injectedService) {
1416
+ delete env.consistency_bookmarks[injectedService];
1417
+ }
1418
+ if (Object.keys(env.consistency_bookmarks).length === 0) {
1419
+ delete env.consistency_bookmarks;
1420
+ }
1421
+ });
1326
1422
  return response;
1327
1423
  };
1328
1424
  }
1329
1425
 
1330
1426
  // src/version.ts
1331
- var VERSION = "0.0.15";
1332
- var SPEC_SHA = "dac502bc";
1333
- var SPEC_FETCHED_AT = "2026-06-15T16:41:41.264Z";
1427
+ var VERSION = "0.0.17";
1428
+ var SPEC_SHA = "d0ccc741";
1429
+ var SPEC_FETCHED_AT = "2026-06-16T14:51:59.656Z";
1334
1430
  var API_BASE = "https://api.wspc.ai";
1335
1431
 
1336
1432
  // src/index.ts
@@ -1343,6 +1439,17 @@ var WspcAuthExpiredError = class extends Error {
1343
1439
  };
1344
1440
 
1345
1441
  // src/handwritten/auth/sdk-auth.ts
1442
+ var USER_AGENT = `@wspc/cli/${VERSION}`;
1443
+ async function expiredMessage(res) {
1444
+ try {
1445
+ const body = await res.clone().json();
1446
+ if (!body.error) return void 0;
1447
+ const detail = body.error_description ? `: ${body.error_description}` : "";
1448
+ return `wspc token refresh failed (${body.error}${detail}); re-authenticate via \`wspc login\``;
1449
+ } catch {
1450
+ return void 0;
1451
+ }
1452
+ }
1346
1453
  function createAuthInterceptor(mode) {
1347
1454
  if ("apiKey" in mode) {
1348
1455
  const apiKey = mode.apiKey;
@@ -1350,6 +1457,7 @@ function createAuthInterceptor(mode) {
1350
1457
  return {
1351
1458
  async onRequest(req) {
1352
1459
  req.headers.set("authorization", `Bearer ${apiKey}`);
1460
+ req.headers.set("user-agent", USER_AGENT);
1353
1461
  return req;
1354
1462
  },
1355
1463
  async execute(req) {
@@ -1365,6 +1473,7 @@ function createAuthInterceptor(mode) {
1365
1473
  return {
1366
1474
  async onRequest(req) {
1367
1475
  req.headers.set("authorization", `Bearer ${accessToken}`);
1476
+ req.headers.set("user-agent", USER_AGENT);
1368
1477
  return req;
1369
1478
  },
1370
1479
  async execute(req) {
@@ -1372,7 +1481,10 @@ function createAuthInterceptor(mode) {
1372
1481
  if (first.status !== 401) return first;
1373
1482
  const refreshRes = await fetchImpl(`${mode.baseUrl}/auth/oauth/token`, {
1374
1483
  method: "POST",
1375
- headers: { "content-type": "application/x-www-form-urlencoded" },
1484
+ headers: {
1485
+ "content-type": "application/x-www-form-urlencoded",
1486
+ "user-agent": USER_AGENT
1487
+ },
1376
1488
  body: new URLSearchParams({
1377
1489
  grant_type: "refresh_token",
1378
1490
  refresh_token: refreshToken,
@@ -1380,7 +1492,7 @@ function createAuthInterceptor(mode) {
1380
1492
  })
1381
1493
  });
1382
1494
  if (!refreshRes.ok) {
1383
- throw new WspcAuthExpiredError();
1495
+ throw new WspcAuthExpiredError(await expiredMessage(refreshRes));
1384
1496
  }
1385
1497
  const tokens = await refreshRes.json();
1386
1498
  accessToken = tokens.access_token;
@@ -1450,13 +1562,13 @@ function buildInterceptor(store, resolved, fetchImpl) {
1450
1562
  clientId,
1451
1563
  fetchImpl,
1452
1564
  onTokenRefresh: async ({ accessToken, refreshToken, expiresAt }) => {
1453
- const cfg = await store.read();
1454
- const a = cfg.envs[envName]?.accounts?.[email];
1455
- if (!a) return;
1456
- a.access_token = accessToken;
1457
- a.refresh_token = refreshToken;
1458
- a.access_token_expires_at = expiresAt;
1459
- await store.write(cfg);
1565
+ await store.update((cfg) => {
1566
+ const a = cfg.envs[envName]?.accounts?.[email];
1567
+ if (!a) return;
1568
+ a.access_token = accessToken;
1569
+ a.refresh_token = refreshToken;
1570
+ a.access_token_expires_at = expiresAt;
1571
+ });
1460
1572
  }
1461
1573
  });
1462
1574
  }