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 +15 -0
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/dist/src/api.d.ts +2 -0
- package/dist/src/api.js +17 -1
- package/dist/src/credentials.d.ts +1 -0
- package/dist/src/credentials.js +4 -0
- package/dist/src/main.js +9 -0
- package/dist/src/pty-fallback.js +31 -16
- package/dist/src/render.d.ts +2 -2
- package/dist/src/render.js +40 -6
- package/dist/src/server.d.ts +14 -0
- package/dist/src/server.js +54 -27
- package/dist/src/update.d.ts +8 -1
- package/dist/src/update.js +24 -8
- package/package.json +7 -3
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
|
-
|
|
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)
|
package/dist/src/credentials.js
CHANGED
|
@@ -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) {
|
package/dist/src/pty-fallback.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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)
|
package/dist/src/render.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/render.js
CHANGED
|
@@ -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
|
|
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
|
|
33
|
-
const
|
|
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
|
-
|
|
42
|
-
|
|
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()}`));
|
package/dist/src/server.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
res.
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
server.listen(PORT, HOST, () => {
|
|
41
|
-
|
|
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
|
package/dist/src/update.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/update.js
CHANGED
|
@@ -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 =
|
|
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
|
|
64
|
+
// npm missing, offline, or timed out — try the public registry directly
|
|
49
65
|
}
|
|
50
66
|
try {
|
|
51
|
-
const res = await
|
|
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
|
+
"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
|
-
"@
|
|
59
|
-
"
|
|
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
|
}
|