fix(cli): plans link uses from=cli and follows HYPERFRAMES_ENV; no balance notice beside the plan

- pageUrl sets from=cli, the parameter the pricing page reads to send a
  CLI buyer back to the terminal.
- The plans and credits pages are on dev.hyperframes.dev with
  HYPERFRAMES_ENV=dev, hyperframes.studio otherwise.
- auth status --json omits the 'HyperFrames balance is unavailable' notice
  when the plan was read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-10-30 08:38:32 +00:00
co-authored by Claude Opus 5.5
parent 76c14e766a
commit 55e08df8cb
7 changed files with 64 additions and 14 deletions
+3 -1
View File
@@ -2262,7 +2262,9 @@ allowance or estimate what the next video will cost.
### `billing`
Opens the HyperFrames plans or credits page in the browser, then waits until the
account shows the change, so an agent's run carries on by itself.
account shows the change, so an agent's run carries on by itself. The page is on
the web app for `HYPERFRAMES_ENV`: hyperframes.studio, or dev.hyperframes.dev
with `HYPERFRAMES_ENV=dev`.
```bash
npx hyperframes billing subscribe
+1 -1
View File
@@ -42,7 +42,7 @@ ${c.bold("ENV VARS:")}
${c.accent("HYPERFRAMES_API_KEY")} Alias for HEYGEN_API_KEY.
${c.accent("HEYGEN_ACCESS_TOKEN")} OAuth token from a host app; used after the API keys, never refreshed or saved.
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HYPERFRAMES_ENV")} prod (default) or dev; selects OAuth/API hosts together.
${c.accent("HYPERFRAMES_ENV")} prod (default) or dev; selects OAuth, API and plans-page hosts together.
${c.accent("HYPERFRAMES_CONFIG_DIR")} Override the credentials directory (default ~/.hyperframes).
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
${c.accent("HYPERFRAMES_OAUTH_DEVICE_URL")} Override the RFC 8628 device endpoint (for dev/test).
@@ -51,6 +51,7 @@ describe("auth status — persisted user block surface", () => {
afterEach(async () => {
consumeCommandResult();
vi.restoreAllMocks();
vi.unstubAllGlobals();
await envFixture.restore();
});
@@ -131,6 +132,31 @@ describe("auth status — persisted user block surface", () => {
}
});
it("gives no 'balance unavailable' notice when the HyperFrames plan is shown", async () => {
vi.stubGlobal(
"fetch",
async () =>
new Response(
JSON.stringify({
data: {
subscription: { plan_name: "pro", status: "active" },
usage: { remaining_units: 25000 },
},
}),
),
);
await configureBillingCredential("hyperframes");
expect(await runStatus(true)).toBe(0);
const payload = lastJson();
expect(payload["hyperframes"]).toEqual({ plan: "pro", credits: 25000 });
expect(payload).not.toHaveProperty("billing_notice");
stdout = [];
expect(await runStatus(false)).toBe(0);
const text = stdout.join("\n");
expect(text).toContain("pro plan, 25,000 credits");
expect(text).not.toContain("balance unavailable");
});
it("surfaces the persisted user block (with resolved display_name) for a file credential", async () => {
await writeStore({
api_key: "hg_x",
+7 -4
View File
@@ -215,10 +215,13 @@ function printJsonStatus(s: VerifiedStatus): void {
};
if (s.credential.type === "oauth") {
payload["hyperframes_balance"] = null;
payload["billing_notice"] =
product === "hyperframes"
? "HyperFrames balance is unavailable here; user billing fields describe the HeyGen account."
: "OAuth billing product is not verified here; user billing fields describe the HeyGen account.";
// With the plan read, `hyperframes` above holds the plan and credits, so there is no notice to give.
if (product !== "hyperframes")
payload["billing_notice"] =
"OAuth billing product is not verified here; user billing fields describe the HeyGen account.";
else if (!s.plan)
payload["billing_notice"] =
"HyperFrames balance is unavailable here; user billing fields describe the HeyGen account.";
payload["expires_at"] = s.credential.expires_at?.toISOString() ?? null;
payload["refreshable"] = s.credential.refreshable;
payload["scope"] = s.credential.scope ?? null;
+19 -2
View File
@@ -5,7 +5,7 @@ vi.mock("../cloud/auth.js", () => ({
resolveCloudBaseUrl: () => "https://api.dev.heygen.com",
}));
const { readPlan } = await import("./plan.js");
const { pageUrl, readPlan } = await import("./plan.js");
const asked: string[] = [];
const answer = (data: unknown) =>
@@ -14,7 +14,10 @@ const answer = (data: unknown) =>
return new Response(JSON.stringify({ code: 100, data }));
});
afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("readPlan", () => {
it("reads the plan the way the subscription route sends it", async () => {
@@ -33,3 +36,17 @@ describe("readPlan", () => {
expect((await readPlan())?.plan).toBe("none");
});
});
describe("pageUrl", () => {
it("links the web app for HYPERFRAMES_ENV and tells the page the buyer came from the CLI", () => {
vi.stubEnv("HYPERFRAMES_PLANS_URL", "");
vi.stubEnv("HYPERFRAMES_CREDITS_URL", "");
vi.stubEnv("HYPERFRAMES_ENV", "prod");
expect(pageUrl("plans")).toBe("https://hyperframes.studio/pricing?from=cli");
vi.stubEnv("HYPERFRAMES_ENV", "dev");
expect(pageUrl("plans")).toBe("https://dev.hyperframes.dev/pricing?from=cli");
expect(pageUrl("credits", { needed: 180, tool: "reverse" })).toBe(
"https://dev.hyperframes.dev/account/plan?from=cli&needed_credits=180&tool=reverse",
);
});
});
+7 -5
View File
@@ -1,4 +1,5 @@
// The HyperFrames plan and credits the signed-in account holds, and the web pages that change them.
import { oauthEnvironment } from "../auth/config.js";
import { resolveCloudAuthHeaders, resolveCloudBaseUrl } from "../cloud/auth.js";
export interface Plan {
@@ -46,17 +47,18 @@ export async function readPlan(): Promise<Plan | null> {
}
}
const plansUrl = () => process.env["HYPERFRAMES_PLANS_URL"] || "https://hyperframes.studio/pricing";
const creditsUrl = () =>
process.env["HYPERFRAMES_CREDITS_URL"] || "https://hyperframes.studio/account/plan";
const webBase = () =>
oauthEnvironment() === "dev" ? "https://dev.hyperframes.dev" : "https://hyperframes.studio";
const plansUrl = () => process.env["HYPERFRAMES_PLANS_URL"] || `${webBase()}/pricing`;
const creditsUrl = () => process.env["HYPERFRAMES_CREDITS_URL"] || `${webBase()}/account/plan`;
/** A plans or credits page link carrying the fields the web side reads (source, needed credits, the waiting step). */
/** A plans or credits page link with what the web side reads: `from=cli`, the credits needed, the waiting step. */
export function pageUrl(
kind: "plans" | "credits",
fields: { needed?: number; tool?: string; base?: string } = {},
): string {
const url = new URL(fields.base ?? (kind === "plans" ? plansUrl() : creditsUrl()));
url.searchParams.set("source", "cli");
url.searchParams.set("from", "cli");
if (fields.needed) url.searchParams.set("needed_credits", String(fields.needed));
if (fields.tool) url.searchParams.set("tool", fields.tool);
return url.toString();
+1 -1
View File
@@ -47,7 +47,7 @@ describe("runJob", () => {
reply(402, { error: { code: "hf_subscription_required", message: "no plan" } }),
);
await expect(runJob("/v3/x", {}, "k", (id) => id)).rejects.toMatchObject({
fix: "Subscribe: https://hyperframes.studio/pricing?source=cli",
fix: "Subscribe: https://hyperframes.studio/pricing?from=cli",
});
vi.stubGlobal("fetch", async () =>
reply(402, { code: "hf_usage_exhausted", url: "https://pay.test/credits" }),