quotacap 0.0.25 → 0.0.26

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.
@@ -4,7 +4,7 @@ import { ensureConfig, getDbPath } from "../config.js";
4
4
  import { createServiceClient, ServiceError, ServiceUnavailable, } from "../runtime/client.js";
5
5
  import { readClaim } from "../runtime/owner.js";
6
6
  import { compareVersions } from "../runtime/versions.js";
7
- import { detectChannel, resolveLatestVersion, updateNpm, updateStandalone, writeUpdateCache, } from "../runtime/updates.js";
7
+ import { detectChannel, resolveLatestVersionDetailed, updateNpm, updateStandalone, writeUpdateCache, } from "../runtime/updates.js";
8
8
  import { isServiceManaged, runServiceCommand } from "../service/index.js";
9
9
  import { checkSkew, formatWedged, olderCliWarning, takeoverManaged, takeoverUnmanaged, WedgedError, } from "./takeover.js";
10
10
  export function stoppedDaemonMessage(oldVersion, newVersion) {
@@ -137,6 +137,17 @@ export async function postUpdateTakeover(opts) {
137
137
  function stripV(v) {
138
138
  return v.startsWith("v") ? v.slice(1) : v;
139
139
  }
140
+ // A rate-limited response names its reset (local HH:MM) and the pin escape
141
+ // hatch; every other failure keeps the generic network message.
142
+ function rateLimitError(resetsAtMs) {
143
+ const hint = "or run 'quotacap update --to <version>'";
144
+ if (resetsAtMs === null) {
145
+ return `GitHub's anonymous API rate limit is exhausted; retry later, ${hint}`;
146
+ }
147
+ const d = new Date(resetsAtMs);
148
+ const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
149
+ return `GitHub's anonymous API rate limit is exhausted (resets ${hhmm}); retry then, ${hint}`;
150
+ }
140
151
  export function registerUpdateCommand(program, deps) {
141
152
  const exit = deps?.exit ?? process.exit;
142
153
  const fetchFn = deps?.fetchFn ?? fetch;
@@ -146,7 +157,7 @@ export function registerUpdateCommand(program, deps) {
146
157
  .command("update")
147
158
  .description("check for and apply updates")
148
159
  .option("--check", "only check for updates, changing nothing")
149
- .option("--version <v>", "update to a specific version")
160
+ .option("--to <version>", "update to a specific version")
150
161
  .option("--json", "machine-readable output")
151
162
  .action(async (o) => {
152
163
  const current = VERSION;
@@ -176,17 +187,24 @@ export function registerUpdateCommand(program, deps) {
176
187
  exit(1);
177
188
  return;
178
189
  }
179
- const pin = typeof o.version === "string" && o.version ? stripV(o.version) : null;
190
+ const pin = typeof o.to === "string" && o.to ? stripV(o.to) : null;
180
191
  let latest = null;
192
+ let rateLimited = null;
181
193
  try {
182
- latest = await resolveLatestVersion({ fetchFn });
194
+ const resolved = await resolveLatestVersionDetailed({ fetchFn });
195
+ if (resolved.status === "ok")
196
+ latest = resolved.release;
197
+ else if (resolved.status === "rate-limited")
198
+ rateLimited = resolved;
183
199
  }
184
200
  catch {
185
201
  latest = null;
186
202
  }
187
203
  const target = pin ?? latest?.version ?? null;
188
204
  if (!target) {
189
- const error = "could not resolve the latest release; check your network and retry";
205
+ const error = rateLimited
206
+ ? rateLimitError(rateLimited.resetsAtMs)
207
+ : "could not resolve the latest release; check your network and retry";
190
208
  if (asJson) {
191
209
  emit({
192
210
  channel,
@@ -13,7 +13,21 @@ export interface ReleaseInfo {
13
13
  url: string;
14
14
  }
15
15
  export declare function releasesApiUrl(): string;
16
+ export declare function releasesPageUrl(): string;
16
17
  export declare function releaseDownloadBase(version: string): string;
18
+ export type LatestVersionResolution = {
19
+ status: "ok";
20
+ release: ReleaseInfo;
21
+ } | {
22
+ status: "rate-limited";
23
+ resetsAtMs: number | null;
24
+ } | {
25
+ status: "unavailable";
26
+ };
27
+ export declare function resolveLatestVersionDetailed(opts?: {
28
+ fetchFn?: typeof fetch;
29
+ timeoutMs?: number;
30
+ }): Promise<LatestVersionResolution>;
17
31
  export declare function resolveLatestVersion(opts?: {
18
32
  fetchFn?: typeof fetch;
19
33
  timeoutMs?: number;
@@ -23,11 +37,13 @@ export interface UpdateCache {
23
37
  latest: string;
24
38
  channel: string;
25
39
  current: string;
40
+ lastFailureAt?: string;
26
41
  }
27
42
  export declare function updatesCachePath(): string;
28
43
  export declare function readUpdateCache(p?: string): UpdateCache | null;
29
44
  export declare function writeUpdateCache(cache: UpdateCache, p?: string): void;
30
45
  export declare const UPDATE_CACHE_TTL_MS: number;
46
+ export declare const UPDATE_FAILURE_RETRY_MS: number;
31
47
  export declare function updateCacheStale(cache: UpdateCache | null, nowMs?: number): boolean;
32
48
  export declare function refreshUpdateCache(opts: {
33
49
  channel: string;
@@ -129,6 +129,33 @@ export function releasesApiUrl() {
129
129
  return `${base}/api/releases/latest`;
130
130
  return "https://api.github.com/repos/carlosboeing/quotacap/releases/latest";
131
131
  }
132
+ // Default version endpoint: the releases page answers 302 to the latest tag
133
+ // and sits outside the anonymous API rate-limit bucket shared with every
134
+ // other API consumer on the machine.
135
+ export function releasesPageUrl() {
136
+ return "https://github.com/carlosboeing/quotacap/releases/latest";
137
+ }
138
+ // Header read that tolerates real Headers and the plain-object headers that
139
+ // hermetic fetch mocks use.
140
+ function responseHeader(res, name) {
141
+ const headers = res.headers;
142
+ if (!headers)
143
+ return null;
144
+ if (typeof headers.get === "function") {
145
+ return headers.get(name);
146
+ }
147
+ if (typeof headers === "object") {
148
+ for (const [k, v] of Object.entries(headers)) {
149
+ if (k.toLowerCase() === name && typeof v === "string")
150
+ return v;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ function tagFromReleaseUrl(location) {
156
+ const m = /\/releases\/tag\/([^/?#]+)/.exec(location);
157
+ return m ? m[1] : "";
158
+ }
132
159
  export function releaseDownloadBase(version) {
133
160
  const base = process.env.QUOTACAP_RELEASE_BASE_URL?.replace(/\/+$/, "");
134
161
  if (base)
@@ -137,32 +164,66 @@ export function releaseDownloadBase(version) {
137
164
  ? "https://github.com/carlosboeing/quotacap/releases/latest/download"
138
165
  : `https://github.com/carlosboeing/quotacap/releases/download/v${version}`;
139
166
  }
140
- // Latest published version, or null when the release cannot be resolved
141
- // (network failure, non-200, bad JSON, unparseable tag). Never throws.
142
- export async function resolveLatestVersion(opts = {}) {
167
+ function rateLimitOrUnavailable(res) {
168
+ if (responseHeader(res, "x-ratelimit-remaining") === "0") {
169
+ const reset = responseHeader(res, "x-ratelimit-reset");
170
+ const secs = reset !== null && reset.trim() !== "" ? Number(reset) : NaN;
171
+ return {
172
+ status: "rate-limited",
173
+ resetsAtMs: Number.isFinite(secs) ? secs * 1000 : null,
174
+ };
175
+ }
176
+ return { status: "unavailable" };
177
+ }
178
+ // Full resolution outcome: the release, a rate-limited response carrying its
179
+ // reset time, or an undifferentiated failure. Never throws.
180
+ export async function resolveLatestVersionDetailed(opts = {}) {
143
181
  const fetchFn = opts.fetchFn ?? fetch;
144
182
  const timeoutMs = opts.timeoutMs ?? 10000;
183
+ const base = process.env.QUOTACAP_RELEASE_BASE_URL?.replace(/\/+$/, "");
145
184
  try {
146
- const res = await fetchFn(releasesApiUrl(), {
147
- headers: { accept: "application/vnd.github+json" },
185
+ if (base) {
186
+ const res = await fetchFn(releasesApiUrl(), {
187
+ headers: { accept: "application/vnd.github+json" },
188
+ signal: AbortSignal.timeout(timeoutMs),
189
+ });
190
+ if (res.ok) {
191
+ const body = (await res.json());
192
+ const tag = typeof body?.tag_name === "string" ? body.tag_name : "";
193
+ const version = tag.startsWith("v") ? tag.slice(1) : tag;
194
+ if (parseVersion(version)) {
195
+ const url = typeof body?.html_url === "string" && body.html_url
196
+ ? body.html_url
197
+ : "https://github.com/carlosboeing/quotacap/releases/latest";
198
+ return { status: "ok", release: { version, url } };
199
+ }
200
+ }
201
+ return rateLimitOrUnavailable(res);
202
+ }
203
+ const res = await fetchFn(releasesPageUrl(), {
204
+ redirect: "manual",
148
205
  signal: AbortSignal.timeout(timeoutMs),
149
206
  });
150
- if (!res.ok)
151
- return null;
152
- const body = (await res.json());
153
- const tag = typeof body?.tag_name === "string" ? body.tag_name : "";
154
- const version = tag.startsWith("v") ? tag.slice(1) : tag;
155
- if (!parseVersion(version))
156
- return null;
157
- const url = typeof body?.html_url === "string" && body.html_url
158
- ? body.html_url
159
- : "https://github.com/carlosboeing/quotacap/releases/latest";
160
- return { version, url };
207
+ if (res.status >= 300 && res.status < 400) {
208
+ const location = responseHeader(res, "location") ?? "";
209
+ const tag = tagFromReleaseUrl(location);
210
+ const version = tag.startsWith("v") ? tag.slice(1) : tag;
211
+ if (tag && parseVersion(version)) {
212
+ return { status: "ok", release: { version, url: location } };
213
+ }
214
+ }
215
+ return rateLimitOrUnavailable(res);
161
216
  }
162
217
  catch {
163
- return null;
218
+ return { status: "unavailable" };
164
219
  }
165
220
  }
221
+ // Latest published version, or null when the release cannot be resolved
222
+ // (network failure, non-redirect, bad JSON, unparseable tag). Never throws.
223
+ export async function resolveLatestVersion(opts = {}) {
224
+ const r = await resolveLatestVersionDetailed(opts);
225
+ return r.status === "ok" ? r.release : null;
226
+ }
166
227
  export function updatesCachePath() {
167
228
  return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "updates.json");
168
229
  }
@@ -180,6 +241,11 @@ export function readUpdateCache(p = updatesCachePath()) {
180
241
  if (typeof parsed.channel !== "string" || typeof parsed.current !== "string") {
181
242
  return null;
182
243
  }
244
+ if (parsed.lastFailureAt !== undefined &&
245
+ (typeof parsed.lastFailureAt !== "string" ||
246
+ Number.isNaN(new Date(parsed.lastFailureAt).getTime()))) {
247
+ return null;
248
+ }
183
249
  return parsed;
184
250
  }
185
251
  catch {
@@ -201,25 +267,48 @@ export function writeUpdateCache(cache, p = updatesCachePath()) {
201
267
  catch { }
202
268
  }
203
269
  export const UPDATE_CACHE_TTL_MS = 24 * 3600 * 1000;
270
+ // Negative-cache floor: a failed refresh suppresses further network attempts
271
+ // for this long. Short against the daily TTL so a recovered endpoint is
272
+ // re-probed soon, long enough that status/advise/poll storms back off.
273
+ export const UPDATE_FAILURE_RETRY_MS = 30 * 60 * 1000;
204
274
  export function updateCacheStale(cache, nowMs = Date.now()) {
205
275
  if (!cache)
206
276
  return true;
207
277
  return nowMs - new Date(cache.checkedAt).getTime() >= UPDATE_CACHE_TTL_MS;
208
278
  }
209
- // Refresh the daily cache when older than 24 hours or absent. Network failure
210
- // keeps the old cache silently; the result is the cache to read from.
279
+ // Refresh the daily cache when older than 24 hours or absent. A failure
280
+ // inside the retry floor performs no network call and keeps serving the
281
+ // previously cached latest; a fresh failure stamps lastFailureAt so the next
282
+ // attempt backs off. checkedAt is never stamped on failure, so the daily
283
+ // check is unaffected. The result is the cache to read from.
211
284
  export async function refreshUpdateCache(opts) {
212
285
  const cachePath = opts.cachePath ?? updatesCachePath();
213
286
  const existing = readUpdateCache(cachePath);
214
287
  const now = opts.now?.() ?? new Date();
215
288
  if (!updateCacheStale(existing, now.getTime()))
216
289
  return existing;
290
+ if (existing?.lastFailureAt &&
291
+ now.getTime() - new Date(existing.lastFailureAt).getTime() < UPDATE_FAILURE_RETRY_MS) {
292
+ return existing;
293
+ }
217
294
  const latest = await resolveLatestVersion({
218
295
  fetchFn: opts.fetchFn,
219
296
  timeoutMs: opts.timeoutMs ?? 3000,
220
297
  });
221
- if (!latest)
222
- return existing;
298
+ if (!latest) {
299
+ if (!existing)
300
+ return existing;
301
+ // Re-read before stamping: a concurrent refresh may have succeeded while
302
+ // our request was in flight, and stamping our stale snapshot would
303
+ // clobber its newer write and suppress retries for the floor.
304
+ const current = readUpdateCache(cachePath);
305
+ if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
306
+ return current;
307
+ }
308
+ const stamped = { ...existing, lastFailureAt: now.toISOString() };
309
+ writeUpdateCache(stamped, cachePath);
310
+ return stamped;
311
+ }
223
312
  const fresh = {
224
313
  checkedAt: now.toISOString(),
225
314
  latest: latest.version,
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.25";
2
+ export const VERSION = "0.0.26";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quotacap",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "Local quota tracker for AI coding plans with a dashboard, CLI, MCP server, and next-plan recommendations.",
5
5
  "license": "MIT",
6
6
  "keywords": [