Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,7 @@ type AccountStatus = {
} | null;
usagePacks: { total: number; used: number; remaining: number };
totalRemaining: number;
free?: { remainingPercent: number | null; resetsAt: string };
};
activity30d: {
requests: number;
Expand Down Expand Up @@ -806,6 +807,7 @@ function parseAccountStatus(value: unknown): AccountStatus | null {
const activity = asRecord(root.activity30d);
const planValue = billing.plan;
const plan = planValue === null ? null : asRecord(planValue);
const free = billing.free === undefined ? null : asRecord(billing.free);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Explicit free: null degrades the entire ACCOUNT block

The branch mirrors the plan field one line above but drops the null case: billing.free === undefined ? null : asRecord(billing.free). Since asRecord(null) returns {}, a server that serializes the absent optional plan as JSON null produces a non-null free that then fails the guard at 824-827 (typeof free.resetsAt !== "string"), parseAccountStatus returns null, and zro status prints ◇ Details unavailable — losing plan, top-up credits and 30-day activity, with --json reporting accountStatus: "unavailable". A free object that omits remainingPercent fails identically, because undefined !== null. This is exactly the trust-boundary case the PR claims is safe (omission works, explicit null does not).

Suggested fix: const freeRaw = billing.free; const free = freeRaw === null || freeRaw === undefined ? null : asRecord(freeRaw);, treat remainingPercent === undefined like null in the guard, and add tests for free: null, a missing remainingPercent, and an unparseable resetsAt asserting the account still renders.

if (
typeof key.id !== "string" || typeof key.alias !== "string" ||
typeof billing.status !== "string" || typeof billing.currency !== "string" ||
Expand All @@ -818,6 +820,10 @@ function parseAccountStatus(value: unknown): AccountStatus | null {
(plan !== null && (
typeof plan.id !== "string" || typeof plan.name !== "string" ||
!hasNumbers(plan, ["allowance", "used", "remaining"])
)) ||
(free !== null && (
(free.remainingPercent !== null && !hasNumbers(free, ["remainingPercent"])) ||
typeof free.resetsAt !== "string" || Number.isNaN(Date.parse(free.resetsAt))
))
) return null;

Expand All @@ -838,19 +844,34 @@ function printAccountStatus(
const packs = account.billing.usagePacks;
const activity = account.activity30d;

const free = account.billing.free;
const bar = (ratio: number) => {
const width = 24;
const filled = Math.round(ratio * width);
return colors.good("█".repeat(filled)) + colors.muted("░".repeat(width - filled));
};

io.stdout.write(`\n${colors.muted("ACCOUNT")}\n`);
io.stdout.write(
` ${plan ? colors.good("◆") : colors.muted("◇")} Plan ` +
`${plan ? colors.strong(plan.name) : colors.muted("none")} ` +
`${colors.muted(account.billing.status)}\n`,
);
if (free) {
io.stdout.write(` ${colors.good("◆")} Plan ${colors.strong("Free")}\n`);
const resets = colors.muted(`resets in ${formatTimeUntil(free.resetsAt)}`);
io.stdout.write(
free.remainingPercent === null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The remainingPercent === null branch is the production path but is untested

The server sets remainingPercent: null whenever the budget lookup returns nothing (budget fetch failure, account not yet reconciled onto Free), so this branch — rendering just resets in 5h 12m with no bar and no percentage — is the one real Free users will often hit. The new test only exercises remainingPercent: 64, so an inverted ternary or a stray ${free.remainingPercent}% would ship silently.

Suggested fix: add a second status test with free: { remainingPercent: null, resetsAt: ... } asserting the output contains resets in and does not contain % left today or undefined.

? ` ${resets}\n`
: ` ${bar(free.remainingPercent / 100)} ${colors.strong(`${free.remainingPercent}%`)} ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Unclamped remainingPercent crashes zro status

free.remainingPercent arrives from /api/cli/status and is only validated as a finite number (line 825, via hasNumbers) — there is no range check. bar() then computes Math.round((p / 100) * 24) and does "░".repeat(24 - filled), so anything below ~-2 or above ~102 yields a negative repeat count and throws RangeError: Invalid count value: -12. The throw escapes printAccountStatus after the ACCOUNT header and Plan Free line are already written, so the user gets truncated output, no activity block, a raw Invalid count value on stderr and exit 1. This is reachable with a self-hosted or older control plane (ZRO_AUTH_URL can point anywhere), and a negative remaining percent is the natural encoding for an over-quota Free user. Note the paid path is safe only because line 872 clamps before calling bar().

Suggested fix: clamp inside bar() — const filled = Math.round(Math.min(Math.max(ratio, 0), 1) * width) — and clamp the printed percentage the same way so label and bar agree; add tests with remainingPercent: 150 and -5.

`${colors.muted("left today ·")} ${resets}\n`,
);
} else {
io.stdout.write(
` ${plan ? colors.good("◆") : colors.muted("◇")} Plan ` +
`${plan ? colors.strong(plan.name) : colors.muted("none")} ` +
`${colors.muted(account.billing.status)}\n`,
);
}
if (plan) {
const ratio = plan.allowance > 0 ? Math.min(Math.max(plan.used / plan.allowance, 0), 1) : 0;
const width = 24;
const filled = Math.round(ratio * width);
const bar = colors.good("█".repeat(filled)) + colors.muted("░".repeat(width - filled));
io.stdout.write(
` ${bar} ${colors.strong(`${Math.round(ratio * 100)}%`)} ${colors.muted("used")}\n`,
` ${bar(ratio)} ${colors.strong(`${Math.round(ratio * 100)}%`)} ${colors.muted("used")}\n`,
);
}
io.stdout.write(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Free accounts still get a dollar top-up line

The PR states "No dollar amounts are shown for Free", but the Top-up credits line below is unconditional, so the new test's own Free fixture renders ◇ Top-up credits $0.00 left · $0.00 total — an always-zero money line directly under a percentage. A Free account cannot hold pack balance (buying a pack moves it off Free), so this is pure noise that contradicts the stated contract, and the new test only asserts not.toContain("none"), so it cannot catch the mismatch.

Suggested fix: skip the Top-up line (or its money values) when free is set and packs.total === 0, or print "No credits" in words; then assert the intended behavior in the test (expect(output).not.toContain("$")).

Expand All @@ -863,6 +884,12 @@ function printAccountStatus(
);
}

function formatTimeUntil(iso: string): string {
const minutes = Math.max(0, Math.ceil((Date.parse(iso) - Date.now()) / 60_000));
const hours = Math.floor(minutes / 60);
return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
}

function money(value: number, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
Expand Down
25 changes: 25 additions & 0 deletions tests/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,31 @@ describe("zro experience", () => {
expect(output).toContain("Activity 15 requests · 1.3K tokens (30d)");
});

it("shows a Free account its share of today's usage and when it resets, with no plan dollars", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-free-status-"));
const stdout = new PassThrough();
const free = accountStatusResponse();
free.billing = {
...free.billing,
status: "setup_required",
plan: null,
usagePacks: { total: 0, used: 0, remaining: 0 },
totalRemaining: 0,
free: { remainingPercent: 64, resetsAt: new Date(Date.now() + (5 * 60 + 12) * 60_000 - 30_000).toISOString() },
} as typeof free.billing;
const code = await run(["status"], {
...io(home, stdout),
env: { ZRO_API_KEY: "sk-status-secret" },
fetch: async () => Response.json(free),
});

expect(code).toBe(0);
const output = await streamText(stdout);
expect(output).toContain("Plan Free");
expect(output).toContain("64% left today · resets in 5h 12m");
expect(output).not.toContain("none");
});

it("reports a rejected stored key as disconnected", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-rejected-status-"));
const credentialDir = path.join(home, ".config", "zro");
Expand Down
Loading