agy-cli-usage 0.4.3 → 0.4.5

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/CHANGELOG.md CHANGED
@@ -10,6 +10,21 @@
10
10
 
11
11
  * use plain v* tags in release-please ([#12](https://github.com/abruption/agy-cli-usage/issues/12)) ([74b648d](https://github.com/abruption/agy-cli-usage/commit/74b648df24967f71a43095a80e7340a6b5ac2e39)), closes [#9](https://github.com/abruption/agy-cli-usage/issues/9)
12
12
 
13
+ ## [0.4.5](https://github.com/abruption/agy-cli-usage/compare/v0.4.4...v0.4.5) (2026-07-03)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * derive UA from package version, name error classes, bound update-check timeouts, document env vars in --help, show cache freshness in watch mode ([62becdc](https://github.com/abruption/agy-cli-usage/commit/62becdcf4175d2cda3cec8dc3f5bba13005c0d59)), closes [#32](https://github.com/abruption/agy-cli-usage/issues/32)
19
+ * UA version, error class names, update-check timeouts, --help env vars, watch/cache indicator ([#37](https://github.com/abruption/agy-cli-usage/issues/37)) ([62becdc](https://github.com/abruption/agy-cli-usage/commit/62becdcf4175d2cda3cec8dc3f5bba13005c0d59))
20
+
21
+ ## [0.4.4](https://github.com/abruption/agy-cli-usage/compare/v0.4.3...v0.4.4) (2026-07-03)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * PTY cleanup, duration parsing, email extraction, render clamping, test coverage ([#35](https://github.com/abruption/agy-cli-usage/issues/35)) ([de886a2](https://github.com/abruption/agy-cli-usage/commit/de886a2a5fdf9c7777f485fd94eab0f1a13d215f))
27
+
13
28
  ## [0.4.3](https://github.com/abruption/agy-cli-usage/compare/v0.4.2...v0.4.3) (2026-07-02)
14
29
 
15
30
 
package/README.ko.md CHANGED
@@ -131,6 +131,7 @@ npm test # 빌드 후 node --test (자격증명·네트워크 불필요
131
131
  ## 주의
132
132
 
133
133
  - `v1internal:retrieveUserQuotaSummary`는 **비공개·비문서 내부 엔드포인트**입니다. 스키마/호스트가 예고 없이 바뀔 수 있으며, 그때의 안전망이 PTY 폴백입니다. 본인 계정의 사용량 조회 용도로만 사용하세요.
134
+ - **최상위 티어 구독자는 API 경로에서 `account`가 `null`입니다.** API 경로는 `loadCodeAssist` 응답의 `currentTier.upgradeSubscriptionUri`에 붙은 `Email=` 파라미터에서 계정 이메일을 추출하는데, 이미 최상위 티어인 계정은 "업그레이드" URL 자체가 없어 추출할 대상이 없습니다. 이는 비공개 엔드포인트의 알려진 제약이며, `--source pty`(agy 자체가 렌더링한 `/usage` 패널에서 `Account: …`를 직접 읽어오므로 영향받지 않음) 외에는 다른 우회 플래그가 없습니다.
134
135
  - 자격증명은 OS 저장소에서 **읽기만** 하며, refresh 토큰을 되쓰지 않아 `agy` 세션과 충돌하지 않습니다.
135
136
  - 코드에 포함된 OAuth client_id/secret은 `agy` 바이너리의 **installed-app(public)** 값으로, [Google 문서](https://developers.google.com/identity/protocols/oauth2)상 기밀이 아닙니다. 사용자 식별은 각자의 키링 토큰으로 이뤄집니다.
136
137
 
@@ -198,6 +199,7 @@ npm test # 빌드 후 node --test (자격증명·네트워크 불필요
198
199
  - `remainingFraction`(잔여 비율 0–1)을 우선 사용. `available`이 `true`면 풀 쿼타로 간주(패널은 "Quota available" 표시).
199
200
  - `resetsInSeconds`는 `fetchedAt` 기준 상대값, `resetAt`은 절대값. 둘 다 `null` 가능.
200
201
  - `kind`는 인식 시 `weekly`/`5h`로 정규화, 아니면 원본 window/label 문자열.
202
+ - `"source": "api"`로 성공한 응답에서도 `account`가 `null`일 수 있습니다 — 위 주의 섹션 참고(최상위 티어 구독자는 API 경로에 이메일 소스가 없음). 계정 이메일이 반드시 필요하면 `--source pty` 또는 PTY 기반 스냅샷을 사용하세요.
201
203
 
202
204
  ## HTTP API (`npm run serve` / `dist/src/server.js`)
203
205
 
package/README.md CHANGED
@@ -131,6 +131,7 @@ npm test # build, then node --test (no credentials/network; pure logic)
131
131
  ## Caveats
132
132
 
133
133
  - `v1internal:retrieveUserQuotaSummary` is a **private, undocumented endpoint**. Its schema/host may change without notice; the PTY fallback is the safety net. Use it only to check your own account's usage.
134
+ - **`account` is `null` via the API path for top-tier subscribers.** The API path derives the account email from the `Email=` param on `currentTier.upgradeSubscriptionUri` in the `loadCodeAssist` response — accounts already on the top tier have no "upgrade" URL, so there's nothing to extract from. This is a known limitation of the undocumented endpoint, not a bug you can work around via flags other than `--source pty`, which reads `Account: …` straight off agy's own rendered `/usage` panel and isn't affected.
134
135
  - Credentials are **read-only** from the OS store; the refresh token is never written back, so it never conflicts with `agy`'s own session.
135
136
  - The OAuth client_id/secret embedded in the code are `agy`'s **installed-app (public)** values — per [Google's docs](https://developers.google.com/identity/protocols/oauth2) these are not treated as secret. Per-user identity comes from your keyring token, not the client_id.
136
137
 
@@ -198,6 +199,7 @@ Notes for parsing:
198
199
  - Prefer `remainingFraction` (fraction remaining, 0–1). When `available` is `true`, treat as full quota (the panel shows "Quota available").
199
200
  - `resetsInSeconds` is relative to `fetchedAt`; `resetAt` is absolute. Either may be `null`.
200
201
  - `kind` is normalized to `weekly` / `5h` where recognized, otherwise the raw window/label string.
202
+ - `account` may be `null` even on a successful `"source": "api"` response — see the Caveats section above (top-tier subscribers have no email source in the API path). Use `--source pty` / a PTY-sourced snapshot if you need the account email reliably.
201
203
 
202
204
  ## HTTP API (`npm run serve` / `dist/src/server.js`)
203
205
 
package/dist/src/api.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { FetchResult } from './types.js';
2
+ /** Exported for direct unit testing — keeps the UA tied to the real package name/version. */
3
+ export declare function buildUserAgent(): string;
2
4
  declare class ApiError extends Error {
3
5
  status: number;
4
6
  constructor(message: string, status: number);
package/dist/src/api.js CHANGED
@@ -8,7 +8,12 @@
8
8
  //
9
9
  // Captured from live agy traffic (mitmproxy). The internal endpoint is undocumented;
10
10
  // the PTY fallback exists for when it changes.
11
- const UA = `antigravity-usage-monitor/0.1 ${process.platform}/${process.arch}`;
11
+ import { currentVersion } from './update.js';
12
+ /** Exported for direct unit testing — keeps the UA tied to the real package name/version. */
13
+ export function buildUserAgent() {
14
+ return `agy-cli-usage/${currentVersion()} ${process.platform}/${process.arch}`;
15
+ }
16
+ const UA = buildUserAgent();
12
17
  // Antigravity ships against the "daily" Cloud Code host; stable builds use the
13
18
  // plain host. Try daily first (matches current CLI), fall back to prod.
14
19
  const HOSTS = ['daily-cloudcode-pa.googleapis.com', 'cloudcode-pa.googleapis.com'];
@@ -16,9 +21,20 @@ class ApiError extends Error {
16
21
  status;
17
22
  constructor(message, status) {
18
23
  super(message);
24
+ this.name = 'ApiError';
19
25
  this.status = status;
20
26
  }
21
27
  }
28
+ // Known limitation: this is the only email source `loadCodeAssist` exposes —
29
+ // the `Email=` query param on `currentTier.upgradeSubscriptionUri`. Accounts
30
+ // already on the top tier have nothing left to upgrade to, so that URI (and
31
+ // therefore `account`) is `null` for them via the API path even though the
32
+ // PTY path can still show `Account: …` (it reads it straight off agy's own
33
+ // rendered panel, which has an in-app session it can draw on that this
34
+ // unauthenticated-beyond-the-token API response doesn't provide). There is no
35
+ // alternative email field in either `loadCodeAssist` or
36
+ // `retrieveUserQuotaSummary` today — see README Caveats. Use `--source pty`
37
+ // if you need the account email for a top-tier subscriber.
22
38
  function extractEmail(uri) {
23
39
  const m = uri?.match(/[?&]Email=([^&]+)/);
24
40
  if (!m)
@@ -1,4 +1,5 @@
1
1
  declare class CredentialError extends Error {
2
+ constructor(message: string);
2
3
  }
3
4
  interface Cred {
4
5
  accessToken: string;
@@ -35,6 +35,10 @@ const KEYRING_SERVICE = 'gemini';
35
35
  const KEYRING_ACCOUNT = 'antigravity';
36
36
  const B64_PREFIX = 'go-keyring-base64:';
37
37
  class CredentialError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'CredentialError';
41
+ }
38
42
  }
39
43
  // --- raw keyring read --------------------------------------------------------
40
44
  async function readViaNapiEsm() {
package/dist/src/main.js CHANGED
@@ -82,6 +82,15 @@ const HELP = `agy-cli-usage — Antigravity CLI (agy) usage/quota monitor
82
82
  agy-cli-usage --no-cache | --refresh
83
83
  agy-cli-usage update [--check] self-update via npm (--check: report only)
84
84
  agy-cli-usage --version | -v
85
+
86
+ Environment variables:
87
+ AGY_OAUTH_TOKEN_FILE Override the token file path (headless fallback).
88
+ AGY_BIN Path to the agy binary (PTY source). Else resolved from
89
+ PATH, then ~/.local/bin.
90
+ XDG_CACHE_HOME Cache base dir (cache lives at
91
+ <base>/agy-usage/quota.json; default ~/.cache).
92
+ NO_COLOR Disable ANSI color in the rendered panel.
93
+ PORT / HOST HTTP server bind (server mode only).
85
94
  `;
86
95
  /** Exported for direct unit testing via an injected `cacheFile` — not part of the CLI's public surface. */
87
96
  export function readCache(source, channel, cacheFile = CACHE_FILE) {
@@ -12,7 +12,7 @@
12
12
  // Windows (ConPTY). agy shows a welcome screen first, so `/usage` is sent after
13
13
  // a delay and the session is held open long enough to render.
14
14
  import { spawn } from 'node:child_process';
15
- import { writeFileSync, readFileSync, mkdtempSync, existsSync } from 'node:fs';
15
+ import { writeFileSync, readFileSync, mkdtempSync, existsSync, rmSync } from 'node:fs';
16
16
  import { tmpdir, homedir } from 'node:os';
17
17
  import { join, delimiter } from 'node:path';
18
18
  // Resolve the agy binary: explicit AGY_BIN, then PATH, then common install dir.
@@ -79,10 +79,14 @@ async function captureViaNodePty() {
79
79
  async function captureViaPython() {
80
80
  if (process.platform === 'win32')
81
81
  return null;
82
+ // Cleaned up in `finally` below regardless of success/failure so repeated
83
+ // `--watch` runs or repeated fallback triggers don't leak a directory per
84
+ // capture (each holds a helper script + the captured PTY bytes).
82
85
  const dir = mkdtempSync(join(tmpdir(), 'agy-usage-'));
83
- const helper = join(dir, 'drive.py');
84
- const outFile = join(dir, 'out.bin');
85
- writeFileSync(helper, `import os, pty, time, select, signal, struct, fcntl, termios
86
+ try {
87
+ const helper = join(dir, 'drive.py');
88
+ const outFile = join(dir, 'out.bin');
89
+ writeFileSync(helper, `import os, pty, time, select, signal, struct, fcntl, termios
86
90
  AGY = ${JSON.stringify(AGY_BIN)}
87
91
  out = open(${JSON.stringify(outFile)}, "wb")
88
92
  pid, fd = pty.fork()
@@ -107,18 +111,25 @@ try: os.kill(pid, signal.SIGTERM)
107
111
  except Exception: pass
108
112
  out.close()
109
113
  `);
110
- return new Promise((resolve) => {
111
- const proc = spawn('python3', [helper], { stdio: 'ignore' });
112
- proc.on('error', () => resolve(null));
113
- proc.on('exit', () => {
114
- try {
115
- resolve(readFileSync(outFile));
116
- }
117
- catch {
118
- resolve(null);
119
- }
114
+ return await new Promise((resolve) => {
115
+ const proc = spawn('python3', [helper], { stdio: 'ignore' });
116
+ proc.on('error', () => resolve(null));
117
+ proc.on('exit', () => {
118
+ try {
119
+ resolve(readFileSync(outFile));
120
+ }
121
+ catch {
122
+ resolve(null);
123
+ }
124
+ });
120
125
  });
121
- });
126
+ }
127
+ finally {
128
+ try {
129
+ rmSync(dir, { recursive: true, force: true });
130
+ }
131
+ catch { /* best-effort cleanup */ }
132
+ }
122
133
  }
123
134
  // --- VT reconstruction --------------------------------------------------------
124
135
  async function reconstructScreen(raw) {
@@ -139,7 +150,11 @@ async function reconstructScreen(raw) {
139
150
  // --- parse --------------------------------------------------------------------
140
151
  function parseDuration(text) {
141
152
  let seconds = 0;
142
- const d = text.match(/(\d+)\s*day/i);
153
+ // Matches both the spelled-out form ("3 days", "1 day") and the abbreviated
154
+ // form ("3d") that agy's panel may render instead — without the `\b`
155
+ // fallback, only "day"/"days" was recognized and a bare "3d 2h" silently
156
+ // dropped the day component.
157
+ const d = text.match(/(\d+)\s*d(?:ays?)?\b/i);
143
158
  const h = text.match(/(\d+)\s*h(?:our)?/i);
144
159
  const m = text.match(/(\d+)\s*m(?:in)?/i);
145
160
  if (d)
@@ -1,3 +1,3 @@
1
1
  import type { Snapshot } from './types.js';
2
- /** Returns the full panel as a string. */
3
- export declare function renderPanel(snap: Snapshot): string;
2
+ /** Returns the full panel as a string. `nowMs` is injectable for testing. */
3
+ export declare function renderPanel(snap: Snapshot, nowMs?: number): string;
@@ -16,8 +16,15 @@ function barColor(remaining) {
16
16
  return '33'; // yellow
17
17
  return '31'; // red
18
18
  }
19
+ // Clamp to [0,1] — remainingFraction comes from an undocumented private
20
+ // endpoint (or PTY-parsed text), so an out-of-range value must not be shown
21
+ // as-is. Shared by the bar and the percentage text so they never disagree
22
+ // (e.g. bar capped at 100% while the text next to it reads "150.00%").
23
+ function clampFraction(remainingFraction) {
24
+ return remainingFraction == null ? null : Math.max(0, Math.min(1, remainingFraction));
25
+ }
19
26
  function bar(remainingFraction) {
20
- const frac = remainingFraction == null ? 0 : Math.max(0, Math.min(1, remainingFraction));
27
+ const frac = clampFraction(remainingFraction) ?? 0;
21
28
  const filled = Math.round(frac * BAR_WIDTH);
22
29
  const body = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled);
23
30
  return useColor() ? `\x1b[${barColor(remainingFraction)}m${body}\x1b[0m` : body;
@@ -29,8 +36,9 @@ function bucketLine(b) {
29
36
  lines.push(` [${bar(1)}] ${c('32', 'Quota available')}`);
30
37
  }
31
38
  else {
32
- const pct = b.remainingFraction == null ? '—' : `${(b.remainingFraction * 100).toFixed(2)}%`;
33
- const remainPct = b.remainingFraction == null ? '' : `${Math.round(b.remainingFraction * 100)}% remaining`;
39
+ const clamped = clampFraction(b.remainingFraction);
40
+ const pct = clamped == null ? '' : `${(clamped * 100).toFixed(2)}%`;
41
+ const remainPct = clamped == null ? '' : `${Math.round(clamped * 100)}% remaining`;
34
42
  const dur = formatDuration(b.resetsInSeconds);
35
43
  const reset = dur ? ` · ${dim(`Refreshes in ${dur}`)}` : '';
36
44
  lines.push(` [${bar(b.remainingFraction)}] ${pct}`);
@@ -38,14 +46,40 @@ function bucketLine(b) {
38
46
  }
39
47
  return lines.join('\n');
40
48
  }
41
- /** Returns the full panel as a string. */
42
- export function renderPanel(snap) {
49
+ // `--watch`'s default interval (60s) is faster than the 5-minute quota cache
50
+ // TTL, so most refreshes just re-render a cached snapshot with no visual
51
+ // sign the fetch didn't actually happen. `fetchedAt` is set once, when the
52
+ // data was truly fetched (see quota.ts), and carried through untouched on a
53
+ // cache hit — so comparing it to "now" at render time is enough to tell the
54
+ // two cases apart without threading extra state through Snapshot/the cache.
55
+ // A small skew is tolerated so a genuinely fresh fetch isn't mislabeled
56
+ // "cached" just because rendering happened slightly after fetching.
57
+ const FRESHNESS_SKEW_MS = 3_000;
58
+ function formatAge(totalSeconds) {
59
+ const s = Math.max(0, Math.round(totalSeconds));
60
+ if (s < 60)
61
+ return `${s}s`;
62
+ const h = Math.floor(s / 3600);
63
+ const m = Math.floor((s % 3600) / 60);
64
+ if (h > 0)
65
+ return `${h}h ${m}m`;
66
+ return `${m}m ${s % 60}s`;
67
+ }
68
+ function freshnessSuffix(fetchedAt, nowMs) {
69
+ const ageMs = nowMs - new Date(fetchedAt).getTime();
70
+ if (!Number.isFinite(ageMs) || ageMs < FRESHNESS_SKEW_MS)
71
+ return '';
72
+ return dim(` (cached, refreshed ${formatAge(ageMs / 1000)} ago)`);
73
+ }
74
+ /** Returns the full panel as a string. `nowMs` is injectable for testing. */
75
+ export function renderPanel(snap, nowMs = Date.now()) {
43
76
  const out = [];
44
77
  out.push('');
45
78
  out.push(bold(' Models & Quota'));
46
79
  if (snap.account)
47
80
  out.push(` ${dim('Account:')} ${snap.account}`);
48
- out.push(` ${dim(`source: ${snap.source}${snap.host ? ` · ${snap.host}` : ''} · ${snap.fetchedAt}`)}`);
81
+ out.push(` ${dim(`source: ${snap.source}${snap.host ? ` · ${snap.host}` : ''} · ${snap.fetchedAt}`)}` +
82
+ freshnessSuffix(snap.fetchedAt, nowMs));
49
83
  out.push('');
50
84
  for (const g of snap.groups) {
51
85
  out.push(bold(` ${g.name.toUpperCase()}`));
@@ -1,2 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import { type Server } from 'node:http';
3
+ import type { Snapshot } from './types.js';
4
+ type SnapshotFetcher = (opts: {
5
+ source: 'auto';
6
+ channel: 'auto';
7
+ cache: boolean;
8
+ }) => Promise<Snapshot>;
9
+ /**
10
+ * Builds the HTTP server without binding a port.
11
+ * `fetchSnapshot` defaults to the real `getSnapshot` (network/keyring/PTY) —
12
+ * exposed as a parameter purely so tests can inject a stub and exercise the
13
+ * routing/response-shape logic without live credentials or a PTY.
14
+ */
15
+ export declare function createApp(fetchSnapshot?: SnapshotFetcher): Server;
2
16
  export {};
@@ -7,37 +7,64 @@
7
7
  // GET /quota -> normalized snapshot JSON
8
8
  // GET /healthz -> { ok: true }
9
9
  import { createServer } from 'node:http';
10
+ import { realpathSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
10
12
  import { getSnapshot } from './main.js';
11
13
  const PORT = Number(process.env.PORT) || 3007;
12
14
  const HOST = process.env.HOST || '127.0.0.1';
13
- const server = createServer(async (req, res) => {
14
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
15
- if (url.pathname === '/healthz') {
16
- res.writeHead(200, { 'Content-Type': 'application/json' });
17
- res.end(JSON.stringify({ ok: true }));
18
- return;
19
- }
20
- if (url.pathname === '/quota') {
21
- try {
22
- const noCache = url.searchParams.get('refresh') === '1';
23
- const snap = await getSnapshot({ source: 'auto', channel: 'auto', cache: !noCache });
24
- res.writeHead(200, {
25
- 'Content-Type': 'application/json',
26
- 'Access-Control-Allow-Origin': '*',
27
- 'Cache-Control': 'public, max-age=300',
28
- });
29
- res.end(JSON.stringify(snap));
15
+ /**
16
+ * Builds the HTTP server without binding a port.
17
+ * `fetchSnapshot` defaults to the real `getSnapshot` (network/keyring/PTY)
18
+ * exposed as a parameter purely so tests can inject a stub and exercise the
19
+ * routing/response-shape logic without live credentials or a PTY.
20
+ */
21
+ export function createApp(fetchSnapshot = getSnapshot) {
22
+ return createServer(async (req, res) => {
23
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
24
+ if (url.pathname === '/healthz') {
25
+ res.writeHead(200, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ ok: true }));
27
+ return;
30
28
  }
31
- catch (err) {
32
- res.writeHead(502, { 'Content-Type': 'application/json' });
33
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
29
+ if (url.pathname === '/quota') {
30
+ try {
31
+ const noCache = url.searchParams.get('refresh') === '1';
32
+ const snap = await fetchSnapshot({ source: 'auto', channel: 'auto', cache: !noCache });
33
+ res.writeHead(200, {
34
+ 'Content-Type': 'application/json',
35
+ 'Access-Control-Allow-Origin': '*',
36
+ 'Cache-Control': 'public, max-age=300',
37
+ });
38
+ res.end(JSON.stringify(snap));
39
+ }
40
+ catch (err) {
41
+ res.writeHead(502, { 'Content-Type': 'application/json' });
42
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
43
+ }
44
+ return;
34
45
  }
35
- return;
46
+ res.writeHead(404, { 'Content-Type': 'application/json' });
47
+ res.end(JSON.stringify({ error: 'not found' }));
48
+ });
49
+ }
50
+ // Only bind a real port when this file is executed directly (the `serve`
51
+ // script's entry point) — guarded the same way as main.ts's isMainModule()
52
+ // so `createApp` can be imported for unit testing without the import itself
53
+ // starting a live server as a side effect.
54
+ function isMainModule() {
55
+ if (!process.argv[1])
56
+ return false;
57
+ try {
58
+ return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
59
+ }
60
+ catch {
61
+ return false;
36
62
  }
37
- res.writeHead(404, { 'Content-Type': 'application/json' });
38
- res.end(JSON.stringify({ error: 'not found' }));
39
- });
40
- server.listen(PORT, HOST, () => {
41
- process.stdout.write(`agy-usage server on http://${HOST}:${PORT} (GET /quota)\n`);
42
- });
63
+ }
64
+ if (isMainModule()) {
65
+ const server = createApp();
66
+ server.listen(PORT, HOST, () => {
67
+ process.stdout.write(`agy-usage server on http://${HOST}:${PORT} (GET /quota)\n`);
68
+ });
69
+ }
43
70
  //# sourceMappingURL=server.js.map
@@ -9,8 +9,15 @@ export declare function currentVersion(): string;
9
9
  * Returns negative if a<b, 0 if equal, positive if a>b.
10
10
  */
11
11
  export declare function semverCompare(a: string, b: string): number;
12
+ export declare const NPM_VIEW_TIMEOUT_MS = 8000;
13
+ export declare const REGISTRY_FETCH_TIMEOUT_MS = 8000;
14
+ /** Exported for direct unit testing via injection — not part of the public surface. */
15
+ export interface LatestVersionDeps {
16
+ execNpmView?: (pkgName: string, timeoutMs: number) => string;
17
+ fetchRegistry?: (pkgName: string, timeoutMs: number) => Promise<Response>;
18
+ }
12
19
  /** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
13
- export declare function latestVersion(): Promise<string | null>;
20
+ export declare function latestVersion(deps?: LatestVersionDeps): Promise<string | null>;
14
21
  /** Run the update flow. Returns the intended process exit code. */
15
22
  export declare function runUpdate({ checkOnly }?: {
16
23
  checkOnly?: boolean;
@@ -34,26 +34,42 @@ export function semverCompare(a, b) {
34
34
  }
35
35
  return 0;
36
36
  }
37
+ // Both the `npm view` child process and the registry fetch fallback are
38
+ // bounded so a slow/unreachable registry can't hang the update check
39
+ // indefinitely — a timed-out attempt is treated the same as "unavailable"
40
+ // and falls through to the next strategy (or to `latestVersion` returning
41
+ // null, which `runUpdate` reports as "could not determine the latest version").
42
+ export const NPM_VIEW_TIMEOUT_MS = 8_000;
43
+ export const REGISTRY_FETCH_TIMEOUT_MS = 8_000;
44
+ function defaultExecNpmView(pkgName, timeoutMs) {
45
+ return execFileSync('npm', ['view', pkgName, 'version'], {
46
+ encoding: 'utf8',
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ timeout: timeoutMs,
49
+ });
50
+ }
51
+ function defaultFetchRegistry(pkgName, timeoutMs) {
52
+ return fetch(`https://registry.npmjs.org/${pkgName}/latest`, { signal: AbortSignal.timeout(timeoutMs) });
53
+ }
37
54
  /** Latest published version: prefer the user's configured registry (npm view), fall back to public. */
38
- export async function latestVersion() {
55
+ export async function latestVersion(deps = {}) {
56
+ const execNpmView = deps.execNpmView ?? defaultExecNpmView;
57
+ const fetchRegistry = deps.fetchRegistry ?? defaultFetchRegistry;
39
58
  try {
40
- const out = execFileSync('npm', ['view', PKG_NAME, 'version'], {
41
- encoding: 'utf8',
42
- stdio: ['ignore', 'pipe', 'ignore'],
43
- }).trim();
59
+ const out = execNpmView(PKG_NAME, NPM_VIEW_TIMEOUT_MS).trim();
44
60
  if (out)
45
61
  return out;
46
62
  }
47
63
  catch {
48
- // npm missing or offline — try the public registry directly
64
+ // npm missing, offline, or timed out — try the public registry directly
49
65
  }
50
66
  try {
51
- const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`);
67
+ const res = await fetchRegistry(PKG_NAME, REGISTRY_FETCH_TIMEOUT_MS);
52
68
  if (res.ok)
53
69
  return (await res.json()).version;
54
70
  }
55
71
  catch {
56
- // offline
72
+ // offline or timed out
57
73
  }
58
74
  return null;
59
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-cli-usage",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Headless usage/quota monitor for the Antigravity CLI (agy) — reads Cloud Code quota directly, with a PTY fallback. No IDE required.",
5
5
  "type": "module",
6
6
  "types": "dist/src/main.d.ts",
@@ -11,6 +11,7 @@
11
11
  "scripts": {
12
12
  "build": "tsc",
13
13
  "check": "tsc --noEmit",
14
+ "lint": "eslint .",
14
15
  "pretest": "tsc",
15
16
  "test": "node --test dist/test/unit.test.js",
16
17
  "start": "tsc && node dist/src/main.js",
@@ -55,7 +56,10 @@
55
56
  },
56
57
  "license": "MIT",
57
58
  "devDependencies": {
58
- "@types/node": "^26.0.0",
59
- "typescript": "^6.0.3"
59
+ "@eslint/js": "^9.39.4",
60
+ "@types/node": "^18.19.0",
61
+ "eslint": "^9.39.4",
62
+ "typescript": "^6.0.3",
63
+ "typescript-eslint": "^8.62.1"
60
64
  }
61
65
  }