From 159e03b9c874d8b8fa01b0cc2d3fa2bda61e6b39 Mon Sep 17 00:00:00 2001 From: Mehar Khanna Date: Fri, 13 Nov 2026 14:14:38 -0500 Subject: [PATCH] genclass 0.3.8: fixes from a signed-in walk of 0.3.7 (team members, the browser, setup, 58 columns) - A team's member sees the team's Pro, a seat its owner pays for (plan, profile, team; team invite says invites are the owner's). The server's /api/cli/account team_plan now says whose team, its apps, history and what it includes; the overview's plan has its source. - The browser opens only for someone at a terminal, and never with --no-browser (dashboard and apps new kept it). - A request after a package install gets one retry on a connection closed meanwhile; a setup whose app never reached the account says so and ends with exit code 1; a network error says to run it again, not to sign in. - help is laid out for the window; whoami stacks a long app row; the setup's notes wrap under genclass (58 columns); a wrong option to login, whoami or logout is one line. Width follows COLUMNS without a terminal. - A grant is never told to add seats, and a covered app's plan page never holds up setup or status; upgrade --json points at the checkout; profile --json has the trial and price; one price wording. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- packages/cli/CHANGELOG.md | 21 ++++ packages/cli/package.json | 2 +- packages/cli/src/actions.mjs | 18 ++- packages/cli/src/api.mjs | 26 +++- packages/cli/src/app.mjs | 2 +- packages/cli/src/cli.mjs | 132 ++++++++++++-------- packages/cli/src/manage.mjs | 63 ++++++++-- packages/cli/src/paywall.mjs | 6 +- packages/cli/src/state.mjs | 11 +- packages/cli/src/tui.mjs | 7 +- packages/cli/test/actions.test.ts | 15 ++- packages/cli/test/server-account.test.ts | 49 +++++++- packages/cli/test/state.test.ts | 4 +- packages/runtime/CHANGELOG.md | 6 + packages/runtime/bin/lib/account.mjs | 13 +- packages/runtime/bin/lib/init.mjs | 34 +++-- packages/runtime/bin/lib/login.mjs | 35 ++++-- packages/runtime/bin/lib/ui.mjs | 53 +++++++- telemetry-worker/src/cli.ts | 38 +++++- telemetry-worker/test/cli-account.test.ts | 6 +- telemetry-worker/test/cli.test.ts | 4 +- telemetry-worker/test/project-plans.test.ts | 2 +- 23 files changed, 422 insertions(+), 127 deletions(-) diff --git a/package-lock.json b/package-lock.json index 54a00da4..0698527c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16819,7 +16819,7 @@ }, "packages/cli": { "name": "genclass", - "version": "0.3.7", + "version": "0.3.8", "license": "Apache-2.0", "bin": { "genclass": "bin/genclass.mjs" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d5e54c50..fd57a796 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.3.8 + +Fixes from a signed-in walk of 0.3.7 against a fresh account, a team and a grant: + +- **A team's member sees the team's Pro**, a seat its owner pays for: `plan` shows the team's plan, whose team it is, + the team's apps, its history and what it includes (and `--json` has `team_plan` and `cli.via`); `profile` and + `team` say the same; `team invite` says invites come from the team's owner. It never says Free, or that Free is + for one person. +- **The browser opens only for someone at a terminal**: never with `--no-browser` (`dashboard` and `apps new` lost + it before), and never without a terminal (a coding agent, CI, a pipe): the link is printed instead. +- **Setup never ends "done" with the app outside the account.** A request after a package install (which blocks the + command) gets a fresh connection once if the old one was closed meanwhile; if the app still could not be added, + the runtime step says so and the setup ends with exit code 1. The advice is to run it again, not to sign in again. +- **58 columns**: `help` is laid out for the window (side by side when wide, stacked when narrow), `whoami` stacks an + app's key and link under its name when the row does not fit, and the setup's notes wrap. A wrong option to + `login`, `whoami` or `logout` says so in one line. The width follows `COLUMNS` when there is no terminal. +- A grant (set up by GenClass) is never told to add seats, and its app's plan page never holds up setup or `status` + (Continue is offered once, not required). `upgrade --json` without Pro points at the checkout, not at itself; + `profile --json` has the trial and the price; one price wording everywhere: $15 / developer / month. With + `GENCLASS_HOST`, the lines name that host. + ## 0.3.7 The genclass command comes with Pro: diff --git a/packages/cli/package.json b/packages/cli/package.json index 55f94313..c52ac8d4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "genclass", - "version": "0.3.7", + "version": "0.3.8", "description": "GenClass CLI: one command that sets GenClass up, shows your app in the terminal (problems, analytics, events), hands problems to your coding agent, answers questions about your app and tells you what it is missing.", "type": "module", "license": "Apache-2.0", diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index 0aa6b2e0..e3c6c0b2 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -45,10 +45,13 @@ const nap = (ms, signal) => ); }); -/** Opens `url` in the default browser when that makes sense here. Returns whether it tried; never throws. */ +/** + * Opens `url` in the default browser when that makes sense here: never with --no-browser, never without a terminal + * (a coding agent, CI, a pipe: the link is printed instead), never over SSH. Returns whether it tried; never throws. + */ export function openBrowser(url, o = {}) { const env = process.env; - if (o.noBrowser || env.CI || env.SSH_CONNECTION || env.SSH_TTY) return false; + if (o.noBrowser || !interactive() || env.CI || env.SSH_CONNECTION || env.SSH_TTY) return false; if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false; const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]]; try { @@ -300,7 +303,16 @@ export async function addRuntime(state, o = {}) { const code = again ? await again() : cmd === "connect" ? await runLogin("connect", args) : await runInit("init", args); if (code !== 0) return "failed"; // init returns 0 when the person says no to its change: then there is no GenClass to add Vite settings for - if (inspectProject({ cwd: state.cwd }).fresh) return "skipped"; + const after = inspectProject({ cwd: state.cwd }); + if (after.fresh) return "skipped"; + // signed in, GenClass is in the code but the app never reached the account (genclass.dev did not answer): the + // step did not finish, so the walk says so and ends with exit code 1 instead of "done" + const optedOut = args.some((a) => a === "--no-telemetry" || a === "--no-token" || a === "--no-network" || a === "--dry-run"); + if (state.signedIn && !after.token && !optedOut) { + warn("GenClass is in your code, but the app is not in your account yet: run npx genclass again to connect it."); + await addViteSettings(state, o); + return "failed"; + } } return (await addViteSettings(state, o)) === "failed" ? "failed" : "done"; } diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs index 16d4dae2..3be0c9b7 100644 --- a/packages/cli/src/api.mjs +++ b/packages/cli/src/api.mjs @@ -47,7 +47,8 @@ export async function fetchOverview(creds, opts = {}) { ok: true, account, // `via`: "team" for a member covered by a team's plan (a newer server says it; older ones do not) - plan: j.plan && typeof j.plan === "object" && str(j.plan.id, 40) ? { id: str(j.plan.id, 40), name: str(j.plan.name, 40) ?? str(j.plan.id, 40), ...(str(j.plan.via, 20) ? { via: str(j.plan.via, 20) } : {}) } : null, + // `source`: "paid", "comp" (GenClass's grant) or "open" (a newer server says it) + plan: j.plan && typeof j.plan === "object" && str(j.plan.id, 40) ? { id: str(j.plan.id, 40), name: str(j.plan.name, 40) ?? str(j.plan.id, 40), ...(str(j.plan.via, 20) ? { via: str(j.plan.via, 20) } : {}), ...(["paid", "comp", "open"].includes(j.plan.source) ? { source: j.plan.source } : {}) } : null, suggestions: s ? { perDay: num(s.per_day), shown: num(s.shown), usedToday: num(s.used_today) ?? 0, model: s.model === true } : null, projects, github: j.github && typeof j.github === "object" ? { app: j.github.app === true, installUrl: sameOrigin(j.github.install_url, creds.host), connectUrl: sameOrigin(j.github.connect_url, creds.host) } : null, @@ -373,8 +374,27 @@ export async function fetchAccount(creds, opts = {}) { }, billingUrl: sameOrigin(j.billing_url, creds.host), offer: offerOf(j.offer, creds.host), - /** the plan of a paying team the account is in (a newer server says it), else null */ - teamPlan: j.team_plan && typeof j.team_plan === "object" && str(j.team_plan.id, 40) ? { id: str(j.team_plan.id, 40), name: str(j.team_plan.name, 40) ?? str(j.team_plan.id, 40) } : null, + /** + * the plan of a paying team the account is in (a newer server says it), else null: whose team, the apps it covers + * and has, its history and what it includes (a seat the team's owner pays for) + */ + teamPlan: teamPlanOf(j.team_plan), + }; +} + +/** A team's plan as /api/cli/account sends it (team_plan), or null. */ +function teamPlanOf(t) { + if (!t || typeof t !== "object" || !str(t.id, 40)) return null; + const inc = t.includes && typeof t.includes === "object" ? t.includes : null; + return { + id: str(t.id, 40), + name: str(t.name, 40) ?? str(t.id, 40), + source: ["paid", "comp", "open"].includes(t.source) ? t.source : null, + owner: str(t.owner, 120), + apps: numOrNull(t.apps), + usedApps: Number.isInteger(t.used_apps) && t.used_apps >= 0 ? t.used_apps : null, + historyDays: numOrNull(t.history_days), + includes: inc ? { analytics: inc.analytics === true, forwarding: inc.forwarding === true, export: inc.export === true, privateRepositories: inc.private_repositories === true } : null, }; } diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index 05c44065..2e67c0b7 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -281,7 +281,7 @@ export async function appView(o = {}, deps = {}, view) { // the account's plan covers goes on whether or not Continue was clicked; one past what a paying account's plan // covers has said so (Enterprise, by email), and its numbers are not the plan's if (!o.json && interactive()) { - const p = await planNow({ cwd: o.cwd, yes: o.yes }, deps, target.id); + const p = await planNow({ cwd: o.cwd, yes: o.yes, noBrowser: o.noBrowser }, deps, target.id); if (p === "locked") { out(); return 1; diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 3ad21ad6..fa8c23f6 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -21,56 +21,84 @@ import { hostConfig } from "../../runtime/bin/lib/host.mjs"; import { c, err, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { BUILD } from "./assets.mjs"; +import { width, wrap } from "./tui.mjs"; -export const USAGE = `genclass: GenClass in your terminal. Set it up, see what it finds, have it fixed. - - The genclass command comes with Pro: 3 days free, then US$15 a developer a month, with a card - on Stripe. Without Pro: login, logout, whoami, status, plan, upgrade, profile, remove, help. - Free is on the website: https://genclass.dev - - npx genclass where this project stands; on a terminal it walks you through the - next step, then stays open for commands and questions - npx genclass setup the guided setup (--yes: no questions, for a coding agent) - npx genclass status what is done, what is next (--all: every part; --json) - - See - npx genclass dashboard your app, whole: problems found, checks, visitors, agent, GitHub App - npx genclass problems every problem found, by kind and page, and what GenClass did about it - npx genclass analytics visitors: visits, pages, time, where they come from, what they click - (one part in full: analytics pages | sources | devices | clicks | paths | funnels) - npx genclass events the latest events, one per line (--all: each one's mode and trigger) - npx genclass health how GenClass itself runs on your pages (--all: every breakdown) - npx genclass report the period as Markdown (--out report.md) - npx genclass export every stored event, as a file (--from, --to, --format csv, --out) - - Act - npx genclass fix hand a problem to your coding agent (fix 2 | fix stale | fix all; - --print, --copy, --agent claude | codex | cursor-agent | gemini) - npx genclass ask "..." ask about your app in plain words; the model answers from its numbers - npx genclass suggest [url] what your app is missing, most important first (--yes, --no-model, --all) - npx genclass scan 23 visual checks on one page - - Connect - npx genclass connections where alerts go (Slack, Sentry, PostHog, Datadog, a webhook), GitHub, agent - (connections add | test | on | off | remove ) - npx genclass agent your coding agent: is it connected, what it did (agent link: give it - the live site's numbers) - npx genclass funnels funnels (funnels add "Name" /a /b /c | funnels remove "Name") - - Account - npx genclass apps your apps (apps new "Name") - npx genclass plan your plan, its limits and what is used (--open: billing in the browser) - npx genclass upgrade start Pro (its free trial first), in the browser - npx genclass profile your account and plan, as numbered choices - npx genclass team who is on the plan (team invite) - npx genclass login sign in (logout, whoami) - npx genclass remove take GenClass out of this app again +/** The help's sections: [heading, [command, what it does]...]; usage() lays them out for the terminal's width. */ +const HELP = [ + [null, [ + ["npx genclass", "where this project stands; on a terminal it walks you through the next step, then stays open for commands and questions"], + ["npx genclass setup", "the guided setup (--yes: no questions, for a coding agent)"], + ["npx genclass status", "what is done, what is next (--all: every part; --json)"], + ]], + ["See", [ + ["npx genclass dashboard", "your app, whole: problems found, checks, visitors, agent, GitHub App"], + ["npx genclass problems", "every problem found, by kind and page, and what GenClass did about it"], + ["npx genclass analytics", "visitors: visits, pages, time, where they come from, what they click (one part in full: analytics pages | sources | devices | clicks | paths | funnels)"], + ["npx genclass events", "the latest events, one per line (--all: each one's mode and trigger)"], + ["npx genclass health", "how GenClass itself runs on your pages (--all: every breakdown)"], + ["npx genclass report", "the period as Markdown (--out report.md)"], + ["npx genclass export", "every stored event, as a file (--from, --to, --format csv, --out)"], + ]], + ["Act", [ + ["npx genclass fix", "hand a problem to your coding agent (fix 2 | fix stale | fix all; --print, --copy, --agent claude | codex | cursor-agent | gemini)"], + ['npx genclass ask "..."', "ask about your app in plain words; the model answers from its numbers"], + ["npx genclass suggest [url]", "what your app is missing, most important first (--yes, --no-model, --all)"], + ["npx genclass scan ", "23 visual checks on one page"], + ]], + ["Connect", [ + ["npx genclass connections", "where alerts go (Slack, Sentry, PostHog, Datadog, a webhook), GitHub, agent (connections add | test | on | off | remove )"], + ["npx genclass agent", "your coding agent: is it connected, what it did (agent link: give it the live site's numbers)"], + ["npx genclass funnels", 'funnels (funnels add "Name" /a /b /c | funnels remove "Name")'], + ]], + ["Account", [ + ["npx genclass apps", 'your apps (apps new "Name")'], + ["npx genclass plan", "your plan, its limits and what is used (--open: billing in the browser)"], + ["npx genclass upgrade", "start Pro (its free trial first), in the browser"], + ["npx genclass profile", "your account and plan, as numbered choices"], + ["npx genclass team", "who is on the plan (team invite)"], + ["npx genclass login", "sign in (logout, whoami)"], + ["npx genclass remove", "take GenClass out of this app again"], + ]], +]; - Options: --app --range 24h|7d|30d|90d|1y --all --json --cwd --yes - --no-browser --help --version +/** + * The help, laid out for `cols` columns (the terminal's): a command and its words side by side on a wide terminal, + * the words under the command on a narrow one (58 columns and up), every line within `cols`. + */ +export function usage(cols = width()) { + const w = Math.max(40, cols - 2); + const lines = []; + const text = (t, indent = "") => { + for (const l of wrap(t, w - indent.length)) lines.push(`${indent}${l}`); + }; + text("genclass: GenClass in your terminal. Set it up, see what it finds, have it fixed."); + lines.push(""); + text("The genclass command comes with Pro: 3 days free, then $15 / developer / month, with a card on Stripe. Without Pro: login, logout, whoami, status, plan, upgrade, profile, remove, help. Free is on the website: https://genclass.dev", " "); + const side = w >= 78; + const col = 30; + for (const [head, rows] of HELP) { + lines.push(""); + if (head) lines.push(` ${head}`); + for (const [cmd, what] of rows) { + if (side) { + const words = wrap(what, w - col); + lines.push(` ${cmd.padEnd(col - 2)}${words[0] ?? ""}`); + for (const l of words.slice(1)) lines.push(`${" ".repeat(col)}${l}`); + } else { + lines.push(` ${cmd}`); + for (const l of wrap(what, w - 6)) lines.push(` ${l}`); + } + } + } + lines.push(""); + text("Options: --app --range 24h|7d|30d|90d|1y --all --json --cwd --yes --no-browser --help --version", " "); + lines.push(""); + text('Run it in your web app\'s folder. npx runs it without installing; "npm install -g genclass" makes it just "genclass". Every part GenClass sets up is listed by "npx genclass status --all". Docs: https://genclass.dev/docs/cli', " "); + return lines.join("\n"); +} - Run it in your web app's folder. npx runs it without installing; "npm install -g genclass" makes it just "genclass". - Every part GenClass sets up is listed by "npx genclass status --all". Docs: https://genclass.dev/docs/cli`; +/** The help at the default width (80 columns), for tests and anything that wants it as one string. */ +export const USAGE = usage(80); const BOOL = { yes: "yes", y: "yes", json: "json", "no-browser": "noBrowser", "no-model": "noModel", all: "all", help: "help", h: "help", version: "version", v: "version", open: "open", print: "print", copy: "copy" }; const VALUE = { cwd: "cwd", url: "url", app: "app", range: "range", from: "from", to: "to", format: "format", out: "out", agent: "agent" }; @@ -177,7 +205,7 @@ export async function run(argv, ctx = {}) { const { o, rest, error } = parseArgs(args); if (error) { - err(`genclass: ${error}\n\n${USAGE}`); + err(`genclass: ${error}\n\n${usage()}`); return 2; } if (o.version || cmd === "version") { @@ -185,7 +213,7 @@ export async function run(argv, ctx = {}) { return 0; } if (o.help || cmd === "help") { - out(USAGE); + out(usage()); return 0; } const h = hostConfig(); @@ -210,7 +238,7 @@ export async function run(argv, ctx = {}) { const url = o.url ?? rest.find((a) => !a.startsWith("-")) ?? null; const unknown = rest.filter((a) => a.startsWith("-")); if (unknown.length) { - err(`genclass suggest: unknown option ${unknown[0]}\n\n${USAGE}`); + err(`genclass suggest: unknown option ${unknown[0]}\n\n${usage()}`); return 2; } const gate = await paid(); @@ -219,7 +247,7 @@ export async function run(argv, ctx = {}) { } if (cmd !== null && !COMMANDS.includes(cmd)) { const near = nearest(cmd); - err(`genclass: unknown command ${c.bold(cmd)}${near ? `. Did you mean ${c.cyan(`npx genclass ${near}`)}?` : ""}\n\n${USAGE}`); + err(`genclass: unknown command ${c.bold(cmd)}${near ? `. Did you mean ${c.cyan(`npx genclass ${near}`)}?` : ""}\n\n${usage()}`); return 2; } // the commands that take words after their name: sections, picks, subcommands, a question @@ -228,7 +256,7 @@ export async function run(argv, ctx = {}) { const WORDY = ["analytics", "fix", "ask", "connections", "agent", "funnels", "apps", "team"]; if (flags.length || (words.length && !WORDY.includes(cmd))) { const bad = flags[0] ?? words[0]; - err(`genclass: unknown ${bad.startsWith("-") ? "option" : "command"} ${c.bold(bad)}\n\n${USAGE}`); + err(`genclass: unknown ${bad.startsWith("-") ? "option" : "command"} ${c.bold(bad)}\n\n${usage()}`); return 2; } // the words of the commands that take them, before anything is asked of genclass.dev diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index 7d961b6f..cdacd185 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -93,7 +93,7 @@ export async function apps(o = {}, deps = {}) { out(` ${c.cyan(made.planUrl)}`); } // its plan is chosen now, on its page (Pro, or Free with a reason) - if (made?.id && !full) await planNow({ cwd: o.cwd }, deps, made.id); + if (made?.id && !full) await planNow({ cwd: o.cwd, noBrowser: o.noBrowser }, deps, made.id); note("To put GenClass into that app's code, run npx genclass in its folder and pick this app."); } out(); @@ -150,8 +150,11 @@ export async function plan(o = {}, deps = {}) { const offer = pro ? null : (step?.offer ?? (mine.length ? null : a.ok ? a.offer : null)); if (o.json) { const apps = mine.map((p) => ({ id: p.id, name: p.name, plan: p.plan, needs_plan: p.needsPlan, plan_url: p.planUrl, this_folder: p.token === state.app?.token })); - const command = cli ? { paid: cli.paid, ...(pro ? { checkout_url: pro.checkoutUrl, trial_days: pro.trialDays, price: pro.price } : {}) } : null; - out(JSON.stringify(a.ok ? { ok: true, plan: a.plan, access: a.access, limits: a.limits, used: a.used, includes: a.includes, subscription: a.subscription, billing_url: a.billingUrl, offer: offer ?? (pro ? a.offer : null), cli: command, apps } : { ok: true, plan: state.account?.plan ?? null, billing_url: state.account?.billingUrl ?? null, offer, cli: command, partial: a.reauth ? "sign in again for the limits: npx genclass login" : "this genclass.dev says no more" }, null, 2)); + const command = cli ? { paid: cli.paid, ...(cli.paid && cli.via ? { via: cli.via } : {}), ...(pro ? { checkout_url: pro.checkoutUrl, trial_days: pro.trialDays, price: pro.price } : {}) } : null; + // without Pro the command runs no model at all: its runs a day are none here, whatever Free's number on the server + const limits = a.ok && pro ? { ...a.limits, suggestionsPerDay: 0 } : a.ok ? a.limits : null; + // a team's member: `plan` is the account's own (Free), `team_plan` the team's Pro its seat is on + out(JSON.stringify(a.ok ? { ok: true, plan: a.plan, access: a.access, team_plan: a.teamPlan ?? null, limits, used: a.used, includes: a.includes, subscription: a.subscription, billing_url: a.billingUrl, offer: offer ?? (pro ? a.offer : null), cli: command, apps } : { ok: true, plan: state.account?.plan ?? null, billing_url: state.account?.billingUrl ?? null, offer, cli: command, partial: a.reauth ? "sign in again for the limits: npx genclass login" : "this genclass.dev says no more" }, null, 2)); return 0; } top(state); @@ -160,10 +163,21 @@ export async function plan(o = {}, deps = {}) { if (!a.ok) { row("Plan", c.bold(clean(state.account?.plan?.name ?? "not known", 20))); if (a.reauth) note("For its limits and what is used: npx genclass login, once (this sign-in is from before the command could show them)."); + } else if (a.teamPlan && a.teamPlan.id !== a.plan.id) { + // a member of a paying team: its seat is one the team's owner pays for (Pro is per developer), so it shows the + // team's plan, the team's apps and what that plan includes, never its own Free + const t = a.teamPlan; + const inc = t.includes ?? a.includes; + row("Plan", `${c.bold(clean(t.name, 20))}${c.gray(t.owner ? ` on ${clean(t.owner, 40)}'s team` : " on your team")}`); + row("Seat", t.source === "comp" ? "set up by GenClass for the team" : "paid for by the team's owner"); + row("Team apps", t.apps === null ? `${t.usedApps === null ? "" : `${n(t.usedApps)} ${sym.dot} `}no limit` : `${t.usedApps === null ? "" : `${n(t.usedApps)} of `}${n(t.apps)}`); + if (t.historyDays !== null) row("History", `${n(t.historyDays)} days`); + if (a.limits.suggestionsPerDay !== null) row("Model runs", `${n(a.used.suggestionsToday)} of ${n(a.limits.suggestionsPerDay)} used today ${sym.dot} suggest and ask share them`); + const has = [inc.analytics && "visitor analytics", inc.forwarding && "alerts to Slack, Sentry and others", inc.export && "the stored events as a file", inc.privateRepositories && "private repositories"].filter(Boolean); + if (has.length) row("Includes", has.join(", ")); + note("Apps you add go into the team's account, under its plan."); } else { - row("Plan", `${c.bold(clean(a.plan.name, 20))}${a.plan.price ? c.gray(` ${clean(a.plan.price, 40)}`) : ""}${a.access === "comp" ? c.gray(" granted by GenClass") : ""}`); - // a member of a paying team: the commands come with the team's plan (a newer server names it) - if (a.teamPlan && a.teamPlan.id !== a.plan.id) row("Through", `your team's ${clean(a.teamPlan.name, 20)}`); + row("Plan", `${c.bold(clean(a.plan.name, 20))}${a.plan.price && a.access !== "comp" ? c.gray(` ${clean(a.plan.price, 40)}`) : ""}${a.access === "comp" ? c.gray(" set up by GenClass") : ""}`); const trial = a.subscription.status === "trialing" && a.access === "paid" && a.subscription.periodEnd; // the trial says that it is one, and when its first charge is (or that a canceled one charges nothing) if (trial) row("Trial", a.subscription.cancels ? `until ${day(a.subscription.periodEnd)}. Canceled: nothing is charged.` : `until ${day(a.subscription.periodEnd)}. Then the first charge, ${a.plan.price ? clean(a.plan.price, 40) : "the plan's price"}, unless you cancel before.`); @@ -276,7 +290,7 @@ export async function upgrade(o = {}, deps = {}) { const paywall = await import("./paywall.mjs"); if (cli && !cli.paid) { if (o.json) { - out(JSON.stringify(paywall.needsProJson(cli))); + out(JSON.stringify(paywall.needsProJson(cli, true, "upgrade"))); return 1; } top(state); @@ -309,7 +323,9 @@ export async function upgrade(o = {}, deps = {}) { if (!offer) { if (paying) { good(viaTeam ? "You are on your team's Pro: there is nothing to upgrade." : `You are on ${clean(state.account?.plan?.id !== "free" ? (state.account?.plan?.name ?? "Pro") : "Pro", 20)}: there is nothing to upgrade.`); - if (!viaTeam) note("More people on it: add seats in Manage billing, on the billing page."); + // a grant has no subscription and no seat count; a paid plan adds people as seats in Manage billing + if (state.account?.plan?.source === "comp") note("Set up by GenClass: it covers every app, with no seats to add."); + else if (!viaTeam) note("More people on it: add seats in Manage billing, on the billing page."); } else if (here && here.plan.id !== "free") good(`${clean(here.name, 30)} is on ${clean(here.plan.name, 20)}.`); else note("Nothing to upgrade here: genclass.dev sells nothing here."); out(` ${c.gray("Billing, in the browser")}`); @@ -340,15 +356,17 @@ export async function profile(o = {}, deps = {}) { const pro = cli && !cli.paid ? cli : null; const offer = pro ? null : upgradeOffer(state, o.app ?? null); const billingUrl = state.account?.billingUrl ?? hostUrl("/account/billing"); - const onFree = state.account?.plan?.id === "free" || (mine.length > 0 && mine.every((p) => p.plan.id === "free")); + // a team's member: the overview names the team's plan (via "team"), a seat its owner pays for + const viaTeam = state.account?.plan?.via === "team" || cli?.via === "team"; + const onFree = !viaTeam && (state.account?.plan?.id === "free" || (mine.length > 0 && mine.every((p) => p.plan.id === "free"))); if (o.json) { - out(JSON.stringify({ ok: true, account: state.account?.label ?? null, plan: state.account?.plan ?? null, apps: mine.map((p) => ({ id: p.id, name: p.name, plan: p.plan, needs_plan: p.needsPlan })), upgrade_url: pro?.checkoutUrl ?? offer?.checkoutUrl ?? null, cli: cli ? { paid: cli.paid } : null, billing_url: billingUrl })); + out(JSON.stringify({ ok: true, account: state.account?.label ?? null, plan: state.account?.plan ?? null, apps: mine.map((p) => ({ id: p.id, name: p.name, plan: p.plan, needs_plan: p.needsPlan })), upgrade_url: pro?.checkoutUrl ?? offer?.checkoutUrl ?? null, trial_days: pro?.trialDays ?? offer?.trialDays ?? null, price: pro?.price ?? offer?.price ?? null, team_plan: state.account?.teamPlan ?? null, cli: cli ? { paid: cli.paid, ...(cli.paid && cli.via ? { via: cli.via } : {}) } : null, billing_url: billingUrl })); return 0; } top(state); out(` ${c.bold("Profile")}`); row("Signed in as", clean(state.account?.label ?? "", 60)); - row("Plan", onFree ? `Free ${c.gray(pro ? "(website only: the genclass command comes with Pro)" : "(limited)")}` : c.bold(clean(state.account?.plan?.name ?? "", 20))); + row("Plan", onFree ? `Free ${c.gray(pro ? "(website only: the genclass command comes with Pro)" : "(limited)")}` : `${c.bold(clean(state.account?.plan?.name ?? "", 20))}${viaTeam ? c.gray(" on your team: a seat its owner pays for") : state.account?.plan?.source === "comp" ? c.gray(" set up by GenClass") : ""}`); if (mine.length) { out(); out(` ${c.bold("Apps and their plans")}`); @@ -391,7 +409,19 @@ export async function team(o = {}, deps = {}) { if ("code" in s) return s.code; const { state, creds } = s; const billing = state.account?.billingUrl ?? hostUrl("/account/billing"); + // a member works on a paying team's plan: invites and seats are that team's owner's + const member = state.account?.cli?.via === "team" || state.account?.plan?.via === "team"; if ((o.args ?? [])[0] === "invite") { + if (member) { + if (o.json) out(JSON.stringify({ ok: false, error: "invites come from the team's owner: ask them for a link", member: true })); + else { + top(state); + warn("Invites come from the team's owner: ask them for a link."); + note("Your seat is on their team, and they add seats in Manage billing."); + out(); + } + return 1; + } const r = await asking(o, creds, (cr) => createInvite(cr, deps), deps); // on Pro, a refusal for the plan is a full team: seats are added in Manage billing, not by another plan if (!r.ok && r.upgradeUrl && !r.needsPro && accountPaid(state.account)) { @@ -427,6 +457,17 @@ export async function team(o = {}, deps = {}) { } top(state); const t = a.team; + // a member of a paying team (its own account is Free, with no team of its own): the team it is on, its paid seat + if (a.teamPlan && a.teamPlan.id !== a.plan.id && t.used <= 1 && !t.members.length) { + const tp = a.teamPlan; + out(` ${c.bold("Team")} ${c.gray(`${clean(tp.name, 20)}, on ${clean(tp.owner ?? "your team's owner", 50)}'s team`)}`); + for (const m of t.memberships) out(` ${clean(m.label, 50)} ${c.gray(`owner${m.active ? "" : ` ${sym.dot} your seat is not active`}`)}`); + out(` ${clean(state.account?.label ?? "you", 50)} ${c.gray("you, a seat the owner pays for")}`); + out(); + note("The owner invites people and adds seats in Manage billing. Apps you add go into the team's account."); + out(); + return 0; + } out(` ${c.bold("Team")} ${c.gray(t.seats === null ? `${n(t.used)} ${t.used === 1 ? "person" : "people"}` : `${n(t.used)} of ${n(t.seats)} seat${t.seats === 1 ? "" : "s"} used`)}`); out(` ${clean(state.account?.label ?? "you", 50)} ${c.gray("owner")}`); for (const m of t.members) out(` ${clean(m.label, 50)} ${c.gray(`${m.joined ? `joined ${ago(m.joined, deps.now)}` : "member"}${m.active ? "" : ` ${sym.dot} over the seats, cannot open dashboards`}`)}`); diff --git a/packages/cli/src/paywall.mjs b/packages/cli/src/paywall.mjs index 20aed423..b2f68144 100644 --- a/packages/cli/src/paywall.mjs +++ b/packages/cli/src/paywall.mjs @@ -50,7 +50,7 @@ export function noteAccount(who) { * `upgrade`): what is needed, where it starts, and the command that starts it. Signed out, the sign-in comes first. * @param {{ plan?: string, price?: string | null, trialDays?: number | null, checkoutUrl?: string } | null} cli */ -export function needsProJson(cli, signedIn = true) { +export function needsProJson(cli, signedIn = true, from = null) { return { ok: false, error: "the genclass command comes with Pro", @@ -60,7 +60,9 @@ export function needsProJson(cli, signedIn = true) { price: cli?.price ?? null, trial_days: cli?.trialDays ?? null, checkout_url: cli?.checkoutUrl ?? null, - run: signedIn ? "npx genclass upgrade" : "npx genclass login", + // `upgrade` itself points at the checkout (opened in a browser), never at itself + run: !signedIn ? "npx genclass login" : from === "upgrade" ? null : "npx genclass upgrade", + ...(signedIn && from === "upgrade" ? { next: "open checkout_url in a browser to start the trial" } : {}), }; } diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index 3e111b1a..7aaf844d 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -70,7 +70,7 @@ export function hasPlaywright(cwd, project) { } /** What the command costs, said before the sign-in (the server says it in its own words once signed in). */ -export const PRO_TERMS = "The genclass command comes with Pro: 3 days free, then US$15 a developer a month, card on Stripe."; +export const PRO_TERMS = "The genclass command comes with Pro: 3 days free, then $15 / developer / month, card on Stripe."; /** Free's limits as a few words ("3 apps, 7 days of history, your top 3 suggestions"), from the numbers the server sent. */ export function freeLimits(free) { @@ -194,7 +194,7 @@ export async function readState(o = {}, deps = {}) { ? `the saved session is for ${otherHost}` : "", // said before anyone signs up: the command is Pro's, with its trial and price (the owner's call, 2026-11-13) - why: "Opens genclass.dev. Then Pro: 3 days free, then US$15 a developer a month, with a card on Stripe.", + why: "Opens genclass.dev. Then Pro: 3 days free, then $15 / developer / month, with a card on Stripe.", more: `Your account is where your apps, their dashboards and your plan live. New here? It is made on that page. Nothing is typed in the terminal. ${PRO_TERMS} Free stays on the website: one app and its totals.`, run: "npx genclass login", human: "approve the sign-in in your browser", @@ -213,7 +213,7 @@ export async function readState(o = {}, deps = {}) { detail: !me ? creds ? "" - : "3 days free, then US$15 a developer a month" + : "3 days free, then $15 / developer / month" : cli && !cli.paid ? `${cli.price ?? cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free` : ""}` : cli?.paid && cli.via === "team" @@ -277,6 +277,9 @@ export async function readState(o = {}, deps = {}) { title: "App plan", doing: appOffer?.current ? `Continue with ${appOffer.current} for this app` : "Choose this app's plan", state: appOffer ? "todo" : "optional", + // an app the account's plan already covers (paid, or GenClass's grant) waits on nothing: its page's Continue is + // offered once, never required, so a setup with --yes or a coding agent still ends complete + blocking: !(appOffer?.current && !appOffer.needs), detail: !me || !app?.plan ? (app?.shared ? "the team's owner chooses it" : "") : appOffer @@ -409,7 +412,7 @@ export async function readState(o = {}, deps = {}) { repo: repo?.ok ? repo : null, steps, next, - complete: steps.every((x) => x.state === "done" || x.state === "optional"), + complete: steps.every((x) => x.state === "done" || x.state === "optional" || x.blocking === false), }; } diff --git a/packages/cli/src/tui.mjs b/packages/cli/src/tui.mjs index d7d9a3a4..e71f7752 100644 --- a/packages/cli/src/tui.mjs +++ b/packages/cli/src/tui.mjs @@ -26,8 +26,11 @@ const CONTROL = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+", "g /** Colour codes (SGR). */ const COLOUR = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); -/** The width text is wrapped to: the terminal's, kept between 60 and 96 columns. */ -export const width = () => Math.max(60, Math.min(96, Number(process.stdout.columns) || 80)); +/** + * The width text is wrapped to: the terminal's (else COLUMNS, as a coding agent or a pipe sets it), kept between 60 + * and 96 columns. Screens draw within width() - 2, so the narrowest is 58 columns, the window every screen is checked at. + */ +export const width = () => Math.max(60, Math.min(96, Number(process.stdout.columns) || Number(process.env.COLUMNS) || 80)); /** `text` without control characters and terminal escape sequences, whitespace collapsed, at most `max` characters. */ export function clean(text, max = 400) { diff --git a/packages/cli/test/actions.test.ts b/packages/cli/test/actions.test.ts index e0bf2d01..9e73a02d 100644 --- a/packages/cli/test/actions.test.ts +++ b/packages/cli/test/actions.test.ts @@ -6,9 +6,9 @@ import { delimiter, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { WORKFLOW_FILE as COLLECTOR_WORKFLOW } from "../../../telemetry-worker/src/github/workflow-file.js"; -import { addWorkflow, connectAgent, connectGithubApp, mcpServer, planAgentConfig, runtimeArgs, SKILL_PATH, skillText, workflowFor, writeSkill } from "../src/actions.mjs"; +import { addWorkflow, connectAgent, connectGithubApp, mcpServer, openBrowser, planAgentConfig, runtimeArgs, SKILL_PATH, skillText, workflowFor, writeSkill } from "../src/actions.mjs"; import { BUILD, SKILL_FILE, WORKFLOW_FILE } from "../src/assets.mjs"; -import { parseArgs, run, USAGE } from "../src/cli.mjs"; +import { parseArgs, run, usage, USAGE } from "../src/cli.mjs"; import { githubName, gitInfo } from "../src/git.mjs"; import { WORKFLOW_PATH } from "../src/state.mjs"; import { ago, checklist, clean, summary, visible, wrap } from "../src/tui.mjs"; @@ -256,6 +256,17 @@ describe("genclass: the command line (src/cli.mjs, src/main.mjs)", () => { expect(USAGE).not.toMatch(/^ {2}genclass /m); expect(await run(["status", "--frob"])).toBe(2); expect(USAGE).not.toMatch(/[—–]/); + // within the window it is read in (the 0.3.7 walk: lines of 118 at 58 columns): side by side when wide, stacked + // when narrow, never wider than the terminal less 2 + for (const [cols, max] of [[60, 58], [80, 78], [96, 94]] as const) { + const lines = usage(cols).split("\n"); + expect(lines.filter((l) => l.length > max), String(cols)).toEqual([]); + expect(lines.join("\n")).toContain("npx genclass dashboard"); + } + expect(usage(60)).toContain("$15 / developer / month"); + // the browser opens only for someone at a terminal (none here: a coding agent, CI, a pipe), never with --no-browser + expect(openBrowser("https://genclass.dev/account", {})).toBe(false); + expect(openBrowser("https://genclass.dev/account", { noBrowser: true })).toBe(false); }); it("the real entry names itself to the runtime CLI's modules: its commands say genclass, not the runtime package", () => { diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index 03980a5b..8f0b2465 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -10,7 +10,7 @@ import { CLI_ACCOUNT_FROM } from "../../../telemetry-worker/src/cli.js"; import { resetTokenCache } from "../../../telemetry-worker/src/dashboard.js"; import { resetOwnerCache } from "../../../telemetry-worker/src/paid.js"; import { resetStandIns } from "../../../telemetry-worker/src/suggest.js"; -import { cliToken, createApp, detection, now, resetClock, sendBatch, setNow, setPlan, setup, signedIn } from "../../../telemetry-worker/test/account-helpers.js"; +import { call, cliToken, createApp, detection, now, resetClock, sendBatch, setNow, setPlan, setup, signedIn } from "../../../telemetry-worker/test/account-helpers.js"; import { saveCredentials } from "../../runtime/bin/lib/account.mjs"; import { answerLines, ask, forget, marks } from "../src/ask.mjs"; import { askModel, fetchAccount, fetchConnections } from "../src/api.mjs"; @@ -136,7 +136,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(u.code).toBe(1); const uj = await said(() => upgrade({ ...me.o, json: true }, me.deps)); expect(uj.code).toBe(1); - expect(JSON.parse(uj.text)).toMatchObject({ ok: false, needs_pro: true, signed_in: true, plan: "Pro", trial_days: 3, checkout_url: expect.stringContaining(`${HOST}/account/billing?plan=`), run: "npx genclass upgrade" }); + expect(JSON.parse(uj.text)).toMatchObject({ ok: false, needs_pro: true, signed_in: true, plan: "Pro", trial_days: 3, checkout_url: expect.stringContaining(`${HOST}/account/billing?plan=`), run: null, next: "open checkout_url in a browser to start the trial" }); expect(tooWide(u.text)).toEqual([]); expect(flat(u.text)).toContain("Start your 3-day free trial"); expect(flat(u.text)).toContain(`start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); @@ -527,3 +527,48 @@ describe("genclass ask (src/ask.mjs)", () => { expect(empty.text).toContain('npx genclass ask "what is going wrong on my site?"'); }); }); + +describe("a team's member (the 0.3.7 walk): the team's Pro, a seat its owner pays for, never Free", () => { + it("plan, profile and team say the team's Pro and whose team it is; invites are the owner's", async () => { + const { env, sql } = setup(LIVE); + const ada = await account(env); + team3(sql, ada.account.id); + const inv = (await (await call(env, "POST", "/api/team/invites", { body: {}, cookie: ada.cookie })).json()) as { url: string }; + const bob = await signedIn(env, "bob@example.com"); + expect((await call(env, "POST", "/api/team/join", { body: { code: inv.url.split("/").pop() }, cookie: bob.cookie })).status).toBe(200); + saveCredentials({ host: HOST, access_token: await cliToken(env, bob.cookie), account: { id: bob.account.id, email: bob.account.email, github_login: null } }); + const o = { cwd: tempDir() }; + const p = await said(() => plan(o, ada.deps)); + expect(p.code).toBe(0); + expect(tooWide(p.text)).toEqual([]); + expect(flat(p.text)).toContain("Pro on ada@example.com's team"); + expect(flat(p.text)).toContain("Seat paid for by the team's owner"); + expect(flat(p.text)).toContain("Team apps 1 of 3"); + expect(flat(p.text)).toContain("History 90 days"); + expect(flat(p.text)).not.toMatch(/Plan Free|History 7 days|Not included/); + const pj = JSON.parse((await said(() => plan({ ...o, json: true }, ada.deps))).text); + expect(pj.team_plan).toMatchObject({ id: "team", name: "Pro", owner: "ada@example.com", apps: 3, usedApps: 1, historyDays: 90 }); + expect(pj.cli).toMatchObject({ paid: true, via: "team" }); + const pr = await said(() => profile({ ...o, yes: true }, ada.deps)); + expect(flat(pr.text)).toContain("Pro on your team: a seat its owner pays for"); + expect(flat(pr.text)).not.toContain("Plan Free"); + const t = await said(() => team(o, ada.deps)); + expect(tooWide(t.text)).toEqual([]); + expect(flat(t.text)).toContain("Pro, on ada@example.com's team"); + expect(flat(t.text)).toContain("a seat the owner pays for"); + expect(flat(t.text)).not.toMatch(/is for one person|A team comes with Pro/); + const i = await said(() => team({ ...o, args: ["invite"] }, ada.deps)); + expect(i.code).toBe(1); + expect(flat(i.text)).toContain("Invites come from the team's owner: ask them for a link."); + expect(flat(i.text)).not.toContain("Every seat of your plan is taken"); + }); + + it("a grant is set up by GenClass: no seats to add, and its app's Continue never blocks setup", async () => { + const { env, sql } = setup(LIVE); + const me = await account(env); + setPlan(sql, me.account.id, "free", { comp_until: "9999-12-31T00:00:00.000Z", comp_plan: "team" }); + const u = flat((await said(() => upgrade(me.o, me.deps))).text); + expect(u).toContain("Set up by GenClass: it covers every app, with no seats to add."); + expect(u).not.toContain("add seats in Manage billing"); + }); +}); diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index 264e2923..a6516e3a 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -216,8 +216,8 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { const s = await readState({ cwd: dir, skip: ["account"] }, { runGit: noGit }); expect(s.next).toBeNull(); // before the sign-in, the first screen says what the command costs - expect(s.steps[0].why).toBe("Opens genclass.dev. Then Pro: 3 days free, then US$15 a developer a month, with a card on Stripe."); - expect(s.steps[1].detail).toBe("3 days free, then US$15 a developer a month"); + expect(s.steps[0].why).toBe("Opens genclass.dev. Then Pro: 3 days free, then $15 / developer / month, with a card on Stripe."); + expect(s.steps[1].detail).toBe("3 days free, then $15 / developer / month"); // with Pro, a skipped step is not the next one signIn(); const fetchFn = fakeServer({ "GET /api/cli/overview": { json: overview({ plan: { id: "team", name: "Pro" }, cli: { paid: true, via: "plan" } }) } }).fetchFn; diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 14146cbb..abb8ba48 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- CLI (shared with the `genclass` command): a request to genclass.dev that fails on a connection closed while the + command was busy (a package install) is sent once more on a new one; a token that could not be created for a + network error says to run the command again, not to sign in again; `connect` names the host it signs in at. + Under the `genclass` command, rows and notes wrap to the terminal (58 columns and up) and `whoami` stacks a long + app row; `genclass-runtime`'s own output is unchanged. + - The CLI's note under a dashboard link says what each plan shows since genclass.dev's plans changed (2026-11-12): Free the totals for the last 7 days; every problem, its pages, fix prompts and visitor analytics with Pro (3 days free). Run by `genclass`, whose commands come with Pro, it names the terminal's own dashboard instead, and a member diff --git a/packages/runtime/bin/lib/account.mjs b/packages/runtime/bin/lib/account.mjs index 24fbec1e..8a46ca04 100644 --- a/packages/runtime/bin/lib/account.mjs +++ b/packages/runtime/bin/lib/account.mjs @@ -117,7 +117,15 @@ export function deleteCredentials() { * One JSON request to the host. Resolves { ok, status, json } for any HTTP answer (json null when the body is not * JSON) or { ok: false, status: 0, reason } for a network error or timeout. Never rejects. */ -export async function request(path, { method = "GET", token, body, host = apiHost(), fetchFn = globalThis.fetch, timeoutMs = TIMEOUT_MS, signal } = {}) { +export async function request(path, opts = {}) { + const r = await requestOnce(path, opts); + // a connection the server closed while this process was busy (a package install blocks Node, so a kept-alive + // socket dies unseen) fails before the request is sent: once more, on a new connection + if (r.status === 0 && r.stale && !r.cancelled) return requestOnce(path, opts); + return r; +} + +async function requestOnce(path, { method = "GET", token, body, host = apiHost(), fetchFn = globalThis.fetch, timeoutMs = TIMEOUT_MS, signal } = {}) { if (typeof fetchFn !== "function") return { ok: false, status: 0, reason: "this Node.js has no fetch" }; const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), timeoutMs); @@ -140,7 +148,8 @@ export async function request(path, { method = "GET", token, body, host = apiHos } catch (e) { if (signal?.aborted) return { ok: false, status: 0, reason: "cancelled", cancelled: true }; const aborted = e?.name === "AbortError"; - return { ok: false, status: 0, reason: aborted ? `no answer within ${timeoutMs / 1000} s` : `network error (${e?.cause?.code ?? e?.message ?? e})` }; + const code = e?.cause?.code ?? e?.message ?? e; + return { ok: false, status: 0, reason: aborted ? `no answer within ${timeoutMs / 1000} s` : `network error (${code})`, stale: !aborted && (code === "ECONNRESET" || code === "UND_ERR_SOCKET" || code === "EPIPE") }; } finally { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs index 19116739..7715daa6 100644 --- a/packages/runtime/bin/lib/init.mjs +++ b/packages/runtime/bin/lib/init.mjs @@ -17,9 +17,9 @@ import { detectCsp, detectProject, detectState, htmlFiles, littleToWatch, isDir, import { MARK, hasMarker, isEmptyConfig, planRemoval, removeMarked, sinkBase, sinkNoEvidence, switchMetaConfig, switchMode, switchTagConfig, updateConfigText } from "./edit.mjs"; import { AUTO, configFileChange, configFileFor, planInit, scriptTag } from "./plan.mjs"; import { createAccountProject, readCredentials } from "./account.mjs"; -import { hostConfig, hostUrl } from "./host.mjs"; +import { apiHost, hostConfig, hostUrl } from "./host.mjs"; import { LOCAL_FILE, PENDING_TOKEN, START_URL, TOKEN_RE, createProject, readLocal, saveLocal, tokenInFiles } from "./token.mjs"; -import { banner, c, confirm, err, interactive, out, printChange, row, sym } from "./ui.mjs"; +import { banner, c, confirm, err, interactive, more, out, printChange, row, sym } from "./ui.mjs"; import { CMD, HOST_CLI, PKG, PKG_DIR, VERSION, compareVersions, distTags, installedVersion, versionInSpec, versionStatus } from "./version.mjs"; const MODES = ["observe", "guard", "heal"]; @@ -396,8 +396,8 @@ const SINK_WHAT = { function sinksNotice(cfg, where) { if (!cfg.sinks?.length) return; row("Findings", `${c.bold(cfg.sinks.join(", "))} ${c.gray(`(--sink${where ? `, written to ${where}` : ""})`)}`); - out(` Every detection, intervention, undo and breaker trip goes there as well as to the console:`); - for (const s of cfg.sinks) out(` ${sym.dot} ${s}: ${SINK_WHAT[sinkBase(s)] ?? ""}${sinkNoEvidence(s) ? " (the structured fields only, no evidence text)" : ""}`); + more("Every detection, intervention, undo and breaker trip goes there as well as to the console:"); + for (const s of cfg.sinks) more(`${sym.dot} ${s}: ${SINK_WHAT[sinkBase(s)] ?? ""}${sinkNoEvidence(s) ? " (the structured fields only, no evidence text)" : ""}`); } /** Telemetry disclosure: it is on by default, what it sends, how to turn it off, where the policy is. */ @@ -407,9 +407,9 @@ function telemetryNotice(cfg, where) { return; } row("Telemetry", `${c.bold("on")}${cfg.telemetry ? c.gray(" (--telemetry)") : c.gray(" (the default)")}`); - out(` Anonymous counts and decisions go to GenClass's maintainers: no page text, cookies or IP`); - out(` addresses, never from localhost. Off: ${c.cyan(`${CMD} init --no-telemetry`)}`); - out(` What is sent: ${TELEMETRY_URL} · Privacy: ${PRIVACY_URL}`); + more(`Anonymous counts and decisions go to GenClass's maintainers: no page text, cookies or IP addresses, never from localhost. Off: ${c.cyan(`${CMD} init --no-telemetry`)}`); + more(`What is sent: ${TELEMETRY_URL}`); + more(`Privacy: ${PRIVACY_URL}`); } /** @@ -700,20 +700,18 @@ function tokenRow(tk) { const name = c.bold(String(tk.name ?? "").slice(0, 80) || "unnamed"); if (tk.pending && tk.account) { row("Token", `a new app in ${accountWhere(tk.account.account)} ${c.gray("once you apply these changes")}`); - out(` ${actor} then sends this app's name (${name}) to ${c.cyan(hostUrl("/api/account/projects"))} with your`); - out(` CLI session and gets back the app's public token. ${c.cyan("--no-token")}: no token, no network.`); + more(`${actor} then sends this app's name (${name}) to ${c.cyan(hostUrl("/api/account/projects"))} with your CLI session and gets back the app's public token. ${c.cyan("--no-token")}: no token, no network.`); return; } if (tk.pending) { row("Token", `a new one for this app, created at genclass.dev ${c.gray("once you apply these changes")}`); - out(` init then sends this app's name (${name}) to ${c.cyan(hostUrl("/api/projects"))} and gets back`); - out(` a public token and your private dashboard link. ${c.cyan("--no-token")}: no token, no network.`); + more(`init then sends this app's name (${name}) to ${c.cyan(hostUrl("/api/projects"))} and gets back a public token and your private dashboard link. ${c.cyan("--no-token")}: no token, no network.`); return; } if (tk.token) { const from = tk.source === "new" ? "created for this app at genclass.dev" : tk.source === "account" ? `a new app in ${accountWhere(tk.account?.account)}` : tk.source; row("Token", `${tk.token} ${c.gray(`(${from})`)}`); - out(` Public (it ships in your page). The diagnostics above also feed this app's ${tk.app || tk.source === "account" ? "dashboard in your account" : "private dashboard"}.`); + more(`Public (it ships in your page). The diagnostics above also feed this app's ${tk.app || tk.source === "account" ? "dashboard in your account" : "private dashboard"}.`); return; } if (tk.wouldCreate) { @@ -727,12 +725,12 @@ function tokenRow(tk) { // the account's plan is why (its app limit): signing in again would hit the same limit, so the way on is one of // the apps it has, or more room if (tk.account && /^upgrade needed/.test(String(tk.why))) { - out(` GenClass works without it; you only miss the dashboard. Use one of your apps instead:`); - out(` ${c.cyan(`${CMD} connect`)} and pick it, or make room at ${c.cyan(hostUrl("/account"))}`); + more(`GenClass works without it; you only miss the dashboard. Use one of your apps instead: ${c.cyan(`${CMD} connect`)} and pick it, or make room at ${c.cyan(hostUrl("/account"))}`); return; } - out(` GenClass works without it; you only miss the dashboard. ${tk.account ? `Run ${c.cyan(`${CMD} login`)} again, then` : `Get a token at ${c.cyan(START_URL)}`}`); - out(` ${tk.account ? c.cyan(`${CMD} connect`) : `and run ${c.cyan(`${CMD} init --token gc_...`)}`}`); + // a network error is not the sign-in: the session is fine, so the way on is to run it again + const net = /^network error|^no answer within/.test(String(tk.why)); + more(`GenClass works without it; you only miss the dashboard. ${tk.account ? (net ? "The app is not in your account yet: run" : `Run ${c.cyan(`${CMD} login`)} again, then`) : `Get a token at ${c.cyan(START_URL)}`} ${tk.account ? c.cyan(`${CMD} connect`) : `and run ${c.cyan(`${CMD} init --token gc_...`)}`}${tk.account && net ? " again when genclass.dev answers." : ""}`); return; } if (tk.hint) { @@ -773,7 +771,7 @@ function dashboardNotice(tk, cwd, applied, remind = false, saved = null) { if (!applied || !tk.token) return; const link = tk.created?.dashboardUrl ?? tk.app?.dashboard_url ?? hostUrl("/account"); out(` ${c.bold("Your dashboard")} ${c.bold(c.cyan(link))}`); - out(` ${c.gray(`In ${HOST_CLI?.into ? HOST_CLI.into : `your GenClass account (${accountLabel(tk.account?.account)})`}: sign in at genclass.dev to open it.`)}`); + more(c.gray(`In ${HOST_CLI?.into ? HOST_CLI.into : `your GenClass account (${accountLabel(tk.account?.account)})`}: sign in at ${new URL(apiHost()).host} to open it.`), 2); for (const l of FREE_LINES) out(` ${c.gray(l)}`); out(` ${c.gray("It fills once your deployed app runs with telemetry on. Local hosts (localhost, *.local, *.test, private")}`); out(` ${c.gray("networks) do not send by default (telemetry: true opts them in), and visitors who opt out or send")}`); @@ -950,7 +948,7 @@ export async function init(o, ctx = {}) { out(); telemetryNotice(isEmptyConfig(cfg) ? currentConfig(marked) : { ...currentConfig(marked), ...cfg }, null); tokenRow(tk); - if (tk.token && !tk.app && !ctx.creds && readCredentials()) out(` ${c.gray(`Signed in: ${CMD} connect adds this app to your account if it is not there yet.`)}`); + if (tk.token && !tk.app && !ctx.creds && readCredentials()) more(c.gray(`Signed in: ${CMD} connect adds this app to your account if it is not there yet.`)); sinksNotice(cfg, null); cspAdvice(project, files, cfg); out(); diff --git a/packages/runtime/bin/lib/login.mjs b/packages/runtime/bin/lib/login.mjs index baacdbd9..e50bc07c 100644 --- a/packages/runtime/bin/lib/login.mjs +++ b/packages/runtime/bin/lib/login.mjs @@ -32,7 +32,7 @@ import { apiHost, hostConfig, hostUrl } from "./host.mjs"; import { FREE_LINES, UsageError, accountLabel, hostNotice, init, inspectProject, parse, versionNotice } from "./init.mjs"; import { AUTO } from "./plan.mjs"; import { LOCAL_FILE, tokenInFiles } from "./token.mjs"; -import { banner, c, choose, confirm, err, interactive, out, row, sym } from "./ui.mjs"; +import { banner, c, choose, columns, confirm, err, interactive, more, out, row, sym } from "./ui.mjs"; import { CMD, HOST_CLI } from "./version.mjs"; export const USAGE = `Usage: @@ -379,14 +379,14 @@ async function afterLogin(o, creds, me) { if (o.noConnect) return 0; const st = inspectProject(o); if (st.project.refused || !st.project.framework) { - out(` Next: run ${c.cyan(`${CMD} connect`)} in your web app's folder to connect it to your account.`); + more(`Next: run ${c.cyan(`${CMD} connect`)} in your web app's folder to connect it to ${HOST_CLI?.into ?? "your account"}.`, 2); out(` ${c.gray("Your apps' dashboards:")} ${c.cyan(hostUrl("/account"))}`); out(); return 0; } const where = relative(process.cwd(), st.cwd) || "."; const name = st.project.pkg?.name || st.project.name; - const ok = o.yes ? true : await confirm(`Connect ${name} (${where}, ${st.project.label}) to your account?`); + const ok = o.yes ? true : await confirm(`Connect ${name} (${where}, ${st.project.label}) to ${HOST_CLI?.into ?? "your account"}?`); if (ok === null) { out(` Not a terminal: run ${c.cyan(`${CMD} connect --yes`)} here to connect this app.`); out(); @@ -534,11 +534,19 @@ async function whoami() { if (!me.projects.length) out(` None yet. In your app's folder: ${c.cyan(SETUP_CMD)}`); const w = Math.min(28, Math.max(4, ...me.projects.map((p) => p.name.length))); for (const p of me.projects) { - out(` ${c.bold(p.name.padEnd(w))} ${p.token} ${c.gray((p.last_event ? `last event ${when(p.last_event)}` : "no events yet").padEnd(31))} ${p.dashboard_url ? c.cyan(p.dashboard_url) : ""}`.trimEnd()); + const seen = p.last_event ? `last event ${when(p.last_event)}` : "no events yet"; + const one = ` ${p.name.padEnd(w)} ${p.token} ${seen.padEnd(31)} ${p.dashboard_url ?? ""}`.trimEnd(); + // one line when it fits the terminal, else the app's name with its key, when it was last seen and its link under it + if (!HOST_CLI || one.length <= columns() - 2) out(` ${c.bold(p.name.padEnd(w))} ${p.token} ${c.gray(seen.padEnd(31))} ${p.dashboard_url ? c.cyan(p.dashboard_url) : ""}`.trimEnd()); + else { + out(` ${c.bold(p.name)} ${c.gray(seen)}`); + out(` ${p.token}`); + if (p.dashboard_url) out(` ${c.cyan(p.dashboard_url)}`); + } } // Free (on the website) shows one app's totals; through a host CLI the terminal's views come with its plan - if (me.projects.length && HOST_CLI) out(` ${c.gray(`Open a dashboard from its link above. In the terminal, with Pro: ${CMD} dashboard`)}`); - else if (me.projects.length) out(` ${c.gray(`Open a dashboard from its link above, or run ${CMD} dashboard in the app's folder. One app's totals are free; the rest comes with Pro.`)}`); + if (me.projects.length && HOST_CLI) more(c.gray(`Open a dashboard from its link above. In the terminal, with Pro: ${CMD} dashboard`), 2); + else if (me.projects.length) more(c.gray(`Open a dashboard from its link above, or run ${CMD} dashboard in the app's folder. One app's totals are free; the rest comes with Pro.`), 2); out(); return 0; } @@ -637,6 +645,15 @@ const SELF = HOST_CLI ? CMD : "genclass-runtime"; /** The usage text, with the commands written the way they are typed here. */ const usage = () => (HOST_CLI ? USAGE.replace(/^ {2}genclass-runtime /gm, ` ${CMD} `) : USAGE); +/** What each of these commands does, in one sentence, for `--help` under the genclass command. */ +const HOST_HELP = { + login: "signs this computer in to your GenClass account, in your browser (--no-browser: print the link only).", + connect: "connects the app in this folder to your account.", + whoami: "shows the signed-in account and its apps.", + logout: "ends this computer's session at genclass.dev and deletes the saved file.", + dashboard: "prints the link to this app's dashboard (--open: open it).", +}; + /** Entry from bin/genclass-runtime.mjs. Returns the exit code. */ export async function run(cmd, argv) { const h = hostConfig(); @@ -650,13 +667,15 @@ export async function run(cmd, argv) { if (o.app !== undefined && o.name !== undefined) throw new UsageError("--app and --name contradict each other (use an app, or create one)"); } catch (e) { if (e instanceof UsageError) { - err(`${SELF} ${cmd}: ${e.message}\n\n${usage()}`); + // under the genclass command, one short line: its own help names every command, within 58 columns + err(HOST_CLI ? `${SELF} ${cmd}: ${e.message}. Every command and option: ${CMD} help` : `${SELF} ${cmd}: ${e.message}\n\n${usage()}`); return 2; } throw e; } if (o.help) { - out(usage()); + if (HOST_CLI) more(`${CMD} ${cmd}: ${HOST_HELP[cmd] ?? ""} Every command and option: ${CMD} help`, 2); + else out(usage()); return 0; } if (cmd === "login") return login(o); diff --git a/packages/runtime/bin/lib/ui.mjs b/packages/runtime/bin/lib/ui.mjs index 36f99c48..7778a7bf 100644 --- a/packages/runtime/bin/lib/ui.mjs +++ b/packages/runtime/bin/lib/ui.mjs @@ -42,9 +42,58 @@ export function banner(title) { out(); } -/** " ✔ Label value" rows. */ +/** + * The terminal's width (else COLUMNS, as a coding agent or a pipe sets it), between 60 and 96 columns; lines are kept + * within columns() - 2, so 58 columns is the narrowest window they fit. + */ +export const columns = () => Math.max(60, Math.min(96, Number(process.stdout.columns) || Number(env.COLUMNS) || 80)); + +const COLOURS = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const visible = (s) => String(s).replace(COLOURS, "").length; + +/** + * `text` wrapped to `width` visible columns, as lines. Colours stay with their words; a word longer than a line (an + * address) is left whole on a line of its own, so it can still be copied. + */ +export function wrapTo(text, width) { + const lines = []; + for (const para of String(text).split("\n")) { + let line = ""; + for (const word of para.split(/ +/).filter((w) => w !== "")) { + if (line && visible(line) + 1 + visible(word) > width) { + lines.push(line); + line = word; + } else line = line ? `${line} ${word}` : word; + } + lines.push(line); + } + return lines; +} + +/** + * Whether lines are wrapped to the terminal: under the genclass command (every screen fits 58 columns), not under + * genclass-runtime, whose lines stay whole as they always were. + */ +const wraps = () => !!globalThis.__GENCLASS_CLI__; + +/** " ✔ Label value" rows; under the genclass command a long value wraps under itself, within the terminal. */ export function row(label, value, mark = c.green(sym.ok)) { - out(` ${mark} ${c.bold(label.padEnd(10))} ${value}`); + if (!wraps()) { + out(` ${mark} ${c.bold(label.padEnd(10))} ${value}`); + return; + } + const [first = "", ...rest] = wrapTo(value, columns() - 2 - 15); + out(` ${mark} ${c.bold(label.padEnd(10))} ${first}`); + for (const l of rest) out(`${" ".repeat(15)}${l}`); +} + +/** Text under a row (13 columns in); under the genclass command, wrapped within the terminal's width. */ +export function more(text, indent = 13) { + if (!wraps()) { + out(`${" ".repeat(indent)}${text}`); + return; + } + for (const l of wrapTo(text, columns() - 2 - indent)) out(`${" ".repeat(indent)}${l}`); } // ------------------------------------------------------------------------------------------------ diff diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index 91697944..b3b54fed 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -71,14 +71,39 @@ const FULL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/(?!\.{1,2}$)[A-Za-z0-9._-] * genclass ask, the plan it names), so a paying team's member is never told to buy Pro again. `team`: the plan is a * team's, not the account's own. */ -export async function workingAccess(c: C, accountId: string): Promise<{ access: Access; team: boolean }> { +export async function workingAccess(c: C, accountId: string): Promise<{ access: Access; team: boolean; owner: string | null }> { const own = await accountAccess(c.db, accountId, c.env, c.now); let best = own; + let from: string | null = null; for (const owner of await activeOwners(c.db, c.env, accountId, c.now)) { const a = await accountAccess(c.db, owner, c.env, c.now); - if (a.source !== null && better(best, a) === a && a !== best) best = a; + if (a.source !== null && better(best, a) === a && a !== best) { + best = a; + from = owner; + } } - return { access: best, team: best !== own }; + return { access: best, team: best !== own, owner: best !== own ? from : null }; +} + +/** + * A team plan as the command shows it to a member: the plan, whose team it is (the owner's email or GitHub login), + * the apps that plan covers for the team and how many it has, its history and what it includes. The member's seat is + * one its owner pays for (Pro is per developer), so it never reads as Free. + */ +async function teamPlanJson(c: C, work: { access: Access; owner: string | null }): Promise> { + const p = work.access.plan; + const who = work.owner ? await c.db.prepare("SELECT email, github_login FROM accounts WHERE id = ?").bind(work.owner).first<{ email: string | null; github_login: string | null }>() : null; + const count = work.owner ? await c.db.prepare("SELECT COUNT(*) AS n FROM projects WHERE account_id = ?").bind(work.owner).first<{ n: number }>() : null; + return { + id: p.id, + name: p.name, + source: work.access.source, + owner: who ? (who.email ?? (who.github_login ? `@${who.github_login}` : null)) : null, + apps: work.owner ? await appsCovered(c.db, work.owner, c.env, c.now) : (p.limits.apps ?? null), + used_apps: Number(count?.n ?? 0), + history_days: p.limits.history_days, + includes: { analytics: entitled(p, "analytics"), forwarding: entitled(p, "forwarding"), export: entitled(p, "export"), private_repositories: entitled(p, "github_private") }, + }; } /** @@ -122,7 +147,7 @@ async function overview(c: C): Promise { const gh = githubApp(c.env); return json(200, { account: accountJson(a.account), - plan: { id: access.plan.id, name: access.plan.name, ...(team ? { via: "team" } : {}) }, + plan: { id: access.plan.id, name: access.plan.name, source: access.source, ...(team ? { via: "team" } : {}) }, suggestions: { per_day: lim.per_day, shown: lim.shown, used_today: await usedToday(c.db, a.account.id, c.now), model: hasModel(c.env) }, projects: await withPlans(c, a.account.id), github: { app: gh !== null, install_url: gh ? `${c.cfg.origin}/github/install` : null, connect_url: gh?.clientId ? `${c.cfg.origin}/github/connect` : null }, @@ -318,8 +343,9 @@ async function account(c: C): Promise { plan: { id: access.plan.id, name: access.plan.name, price: access.plan.id === FREE.id ? null : priceText(access.plan), summary: access.plan.summary }, // why the account has its plan now: "paid", "comp" (GenClass's own grant), "open" (this server's PLAN_GATES), or null on Free access: access.source, - // the plan the account works under through a team when it is better than its own (workingAccess), else null - team_plan: work.team ? { id: work.access.plan.id, name: work.access.plan.name } : null, + // the plan the account works under through a team when it is better than its own (workingAccess), else null: + // whose team, the apps it covers, its history and what it includes (a seat the team's owner pays for) + team_plan: work.team ? await teamPlanJson(c, work) : null, // the apps the account's plan covers (coverage): no limit for a grant, Pro's 3 or the apps kept from before version 5 limits: { apps: await appsCovered(c.db, a.account.id, c.env, c.now), history_days: access.plan.limits.history_days, seats: access.seats, suggestions_per_day: lim.per_day, suggestions_shown: lim.shown }, used: { apps: apps.filter((p) => p.account_id === a.account.id).length, suggestions_today: await usedToday(c.db, a.account.id, c.now), seats: t.used }, diff --git a/telemetry-worker/test/cli-account.test.ts b/telemetry-worker/test/cli-account.test.ts index e0d42585..050ca3a8 100644 --- a/telemetry-worker/test/cli-account.test.ts +++ b/telemetry-worker/test/cli-account.test.ts @@ -578,11 +578,13 @@ describe("the genclass command is Pro's (src/cli.ts cliPaid; the owner's call of expect((await json(get(t.env, "/api/cli/overview", bob.bearer))).cli).toEqual({ paid: true, via: "team" }); // and he works under the team's plan: its name, its suggestions, no offer of a second Pro (cli.ts workingAccess) const seen = await json(get(t.env, "/api/cli/overview", bob.bearer)); - expect(seen.plan).toEqual({ id: "team", name: "Pro", via: "team" }); + // the plan the member works under: the team's, paid (`source`), through the team (`via`) + expect(seen.plan).toEqual({ id: "team", name: "Pro", source: "paid", via: "team" }); expect(seen.suggestions).toMatchObject({ per_day: 30, shown: null }); expect(seen.offer).toBeNull(); const acct = await json(get(t.env, "/api/cli/account", bob.bearer)); - expect(acct.team_plan).toEqual({ id: "team", name: "Pro" }); + // the team's plan as the member's screens show it: whose team, its apps, its history, what it includes + expect(acct.team_plan).toEqual({ id: "team", name: "Pro", source: "paid", owner: "ada@example.com", apps: 3, used_apps: 1, history_days: 90, includes: { analytics: true, forwarding: true, export: true, private_repositories: true } }); expect(acct.limits).toMatchObject({ suggestions_per_day: 30, suggestions_shown: null }); expect(acct.offer).toBeNull(); // the owner down to one seat: bob is past it, and so gated again diff --git a/telemetry-worker/test/cli.test.ts b/telemetry-worker/test/cli.test.ts index 4eeae8f4..852dd431 100644 --- a/telemetry-worker/test/cli.test.ts +++ b/telemetry-worker/test/cli.test.ts @@ -53,7 +53,7 @@ describe("genclass command: GET /api/cli/overview (src/cli.ts)", () => { expect(r.status).toBe(200); const j = (await r.json()) as any; expect(j.account).toEqual({ id: me.account.id, email: "ada@example.com", github_login: null }); - expect(j.plan).toEqual({ id: "free", name: "Free" }); + expect(j.plan).toEqual({ id: "free", name: "Free", source: null }); // Free is shown one item of the suggestions since 2026-11-12 expect(j.suggestions).toEqual({ per_day: 3, shown: 1, used_today: 0, model: false }); expect(j.projects.map((p: any) => [p.id, p.name, p.token, p.last_event])).toEqual([[app.id, "Shop", app.token, null]]); @@ -105,7 +105,7 @@ describe("genclass command: GET /api/cli/overview (src/cli.ts)", () => { // a plan with more, and a server that has a model setPlan(sql, me.account.id, "free", { comp_until: FUTURE(), comp_plan: "team" }); const team = (await (await call({ ...env, OPENAI_API_KEY: KEY }, "GET", "/api/cli/overview", { bearer: me.bearer, origin: null })).json()) as any; - expect(team.plan).toEqual({ id: "team", name: "Pro" }); + expect(team.plan).toEqual({ id: "team", name: "Pro", source: "comp" }); expect(team.suggestions).toEqual({ per_day: 30, shown: null, used_today: 0, model: true }); // an account with GenClass's grant is offered none, whatever the server sells: since version 5 a grant covers // every app of the account, the account's own or one copied onto an app (migration 0016) diff --git a/telemetry-worker/test/project-plans.test.ts b/telemetry-worker/test/project-plans.test.ts index aeb6c2b3..a15d4998 100644 --- a/telemetry-worker/test/project-plans.test.ts +++ b/telemetry-worker/test/project-plans.test.ts @@ -890,7 +890,7 @@ describe("what was bought before version 5 keeps what it covered, and a paying t const bearer = await cliToken(t.env, m.cookie); const seen = await json(call(t.env, "GET", "/api/cli/overview", { bearer, origin: null })); expect(seen.cli).toEqual({ paid: true, via: "team" }); - expect(seen.plan).toEqual({ id: "team", name: "Pro", via: "team" }); + expect(seen.plan).toEqual({ id: "team", name: "Pro", source: "paid", via: "team" }); } });