diff --git a/PRIVACY.md b/PRIVACY.md index f17c0740..0a0eadcf 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -143,11 +143,13 @@ and invite links. ## Paid plans, payments and Stripe -Free costs nothing, Pro (named Team until 12 November 2026) is sold on genclass.dev per project (since 12 November 2026; per developer from 11 November), and Enterprise is agreed by +Free costs nothing, Pro (named Team until 12 November 2026) is sold on genclass.dev for an account, per developer +(per project on 12 November 2026 only), and Enterprise is agreed by email. A plan decides what your dashboard shows, never what is collected: an app that turned visitor analytics on has -them counted on Free too, and they show once its plan includes them. A paid plan (Team; the Individual plan was one +them counted on Free too, and they show once its plan includes them. A paid plan (Pro; the Individual plan was one until 22 October 2026) is paid through Stripe, which processes payments -for us. We keep which app a subscription pays for (or, for a plan bought per developer, the number of seats), next to its status. When you choose Free for an app, we keep when, the reason you picked and anything you wrote (at most 500 characters), and we read them to improve our plans. When you upgrade, we send Stripe +for us. We keep the number of developers a subscription pays for (for one bought per project on 12 November 2026, +which app it pays for), next to its status. When you choose Free for an app, we keep when, the reason you picked and anything you wrote (at most 500 characters), and we read them to improve our plans. When you upgrade, we send Stripe your account's email (if it has one) and your GenClass account id, and you type your card or other payment details into Stripe's own checkout page: they go to Stripe and never reach GenClass. Stripe handles them under its own privacy policy (stripe.com/privacy) and keeps your invoices and receipts, which you can see under Manage billing on your @@ -279,9 +281,9 @@ repository answers "not found" and is left as it is. We pass the digest to a language model (OpenAI's API) to order the failed checks and word the reasons, to suggest its own ideas for the app and website designs inspired by real sites (we check that each site it names answers), and return its answer. We do not store or log the digest. Since 12 November 2026 we keep the last answer for each app of -an account (the ordered checks with their reasons, the ideas and the designs, not the digest), so the Free plan can -show the same answer again and a paid plan can show all of it; it is replaced by the next answer and deleted with the -account. We also keep how many plans an account asked for on a day, for 3 days, to hold each plan's daily limit. +an account (the ordered checks with their reasons, the ideas and the designs, not the digest), so it can be shown +again once the day's model runs are used up, and in your GenClass report (below); it is replaced by the next answer +and deleted with the account. We also keep how many plans an account asked for on a day, for 3 days, to hold each plan's daily limit. OpenAI processes the digest under its API terms. Nothing from a digest is used to train GenClass's own model. ## Reports and alerts by email diff --git a/README.md b/README.md index 339f308e..c4539a09 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,15 @@ take a prerelease. Both lines load the same model. AI coding agents working on t ## Quick start ```bash -npx genclass # signs you in, adds GenClass to your app and walks you through the rest +npx genclass # signs you in, starts Pro, adds GenClass to your app and walks you through the rest ``` -Run it in your web app's folder and press Enter for each step: account, runtime, dashboard, test workflow, GitHub -App, coding agent, suggestions. A step that does not fit your project is skipped, Enter also stops any wait, and -running it again continues from where things are. It draws nothing on your pages. Then: +Run it in your web app's folder and press Enter for each step: account, Pro, runtime, app plan, dashboard, test +workflow, GitHub App, coding agent, suggestions. The command comes with Pro: 3 days free, then US$15 a developer a +month. The card is asked for on Stripe when the trial starts, and nothing is charged if you cancel before the 3 days +end. Free stays on the website ([pricing](https://genclass.dev/pricing)). A step that does not fit your project is +skipped, Enter also stops any wait, and running it again continues from where things are. It draws nothing on your +pages. Then: ```bash npx genclass dashboard # what GenClass found on your live site, in the terminal @@ -43,8 +46,10 @@ npx genclass suggest # what your app is missing, most important first ``` With a coding agent (Claude Code, Cursor, Codex), tell it to run `npx genclass status --json` and then -`npx genclass setup --yes`: the command asks nothing when an agent runs it, and prints a link for the two things only -you can do (approve the sign-in, install the GitHub App). Guide: [genclass.dev/docs/cli](https://genclass.dev/docs/cli); +`npx genclass setup --yes`: the command asks nothing when an agent runs it, and prints a link for each thing only you +can do in your browser: approve the sign-in, start Pro's trial with your card, open a new app's plan page once, and +install the GitHub App. Without Pro, `setup --yes` stops at the trial with exit code 1 and one line with the checkout +link: the agent gives you the link, waits while you start the trial, then runs `setup --yes` again. Guide: [genclass.dev/docs/cli](https://genclass.dev/docs/cli); package: [`packages/cli`](packages/cli/README.md). To add only the runtime, with no account: @@ -199,7 +204,7 @@ GenClass's GitHub App puts what the runtime saw on the pull request it was seen the GenClass fixture, your workflow uploads the race files it writes, and the app posts one check and one comment: each finding with its test, the seeds it was flagged in and the evidence line. It reads those files and never your source code, it comments only where it is installed, and it is free on public repositories. Private repositories -come with the Team plan (US$15 a developer a month; [pricing](https://genclass.dev/pricing)). +come with Pro (US$15 a developer a month; [pricing](https://genclass.dev/pricing)). > **Install** at (the app is GenClass.dev, ). With > no change to your tests, one workflow file is the whole setup: the check on your first pull request links to it, @@ -243,10 +248,12 @@ export const submitOrder = protect("checkout submit", (cart) => fetch("/api/orde ``` - **Token and dashboard.** A token (`gc_` plus 22 letters and digits, public) groups your app's diagnostics under a - private dashboard link (`https://genclass.dev/dashboard/...`). The dashboard is free for every app, with or without - an account: what GenClass found, where and what it did, for the last 7 days and up to 3 apps in an account. Visitor - analytics, forwarding, export, 90 days of history and team members come with the Team plan - ([pricing](https://genclass.dev/pricing)); the runtime itself is never limited by a plan. Get a token at [genclass.dev/start](https://genclass.dev/start), or let `init` create it after you + private dashboard link (`https://genclass.dev/dashboard/...`). On Free the dashboard shows an app's totals for the + last 7 days, for one app in an account. Pro (US$15 a developer a month, up to 3 apps in an account) adds every + problem with its pages and a fix prompt, visitor analytics, forwarding, export, 90 days of history and team members; + more apps come with Enterprise ([pricing](https://genclass.dev/pricing)). Plans bought before 13 November 2026 keep + every app they cover (an app paid for on its own does not count toward Pro's 3); Free covers an account's oldest app + and locks the others. The runtime itself is never limited by a plan. Get a token at [genclass.dev/start](https://genclass.dev/start), or let `init` create it after you approve its diff. To keep dashboards in an optional genclass.dev account, `npx @genclass/runtime@beta login` signs in from the terminal (device flow) and connects the app in the current folder; `connect`, `whoami` and `logout` do the rest, and `npx @genclass/runtime@beta dashboard` prints an app's link again (from `0.3.0-beta.2`). The dashboard @@ -316,7 +323,7 @@ very heavy page can cause a long task, which the runtime records as a `perf` eve double submit or a write race usually throws nothing, so there is no error to collect. GenClass looks for those situations inside the page, decides on the user's device and reports what it saw. It does not replace an error tracker: from `0.3.0` its reports can go to your own logging through `sinks` ([SINKS.md](docs/runtime/SINKS.md)), - and for apps in a genclass.dev account (any plan, Free included) the collector can forward likely detections to + and for apps in a genclass.dev account on Pro or Enterprise the collector can forward likely detections to Sentry, PostHog, Datadog, Slack or a signed webhook. - **Product analytics.** Analytics count what users do; GenClass reports what the app got wrong while they did it. - **Tests and linters.** These bugs depend on response order and user timing. A test on a fast, ordered network can diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a4f2543e..d5e54c50 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -21,6 +21,41 @@ The genclass command comes with Pro: - The header shows this folder's app's plan. The note under a dashboard link says what Free shows (the totals) and what comes with Pro. +Fixed before release, from a test report on the 0.3.7 pull request and a walk of 0.3.6 at 58 columns: + +- **The telemetry opt-out works without Pro.** `npx genclass init --no-telemetry` (and `--telemetry`, to undo it) on + an app that has GenClass changes only that option, signed out too. It no longer opens a checkout. +- **`genclass login` only signs in.** An account without Pro sees Pro's card next, and nothing is written in the + folder. This folder's app is offered to the account (the runtime login's own question) once Pro is on. +- **The home's commands pass the gate**, like any command: the home can open before the sign-in (q at the first + step). Skipping the sign-in skips the steps after Pro too. +- **A 4th app on Pro** says that more apps come with Enterprise, by email, with the address on its plan page. Nothing + is opened or waited for, and the walk goes on. `upgrade` and `apps new` say the same. +- **Pro with one seat:** `team` and `team invite` say to add seats in Manage billing, not that a team needs Pro. + `upgrade` on Pro says there is nothing to upgrade, and where seats are added. +- **Said before the sign-in:** the first screen and `--help` say that the command comes with Pro (3 days free, then + US$15 a developer a month, card on Stripe). `--help` lists `upgrade`, `profile` and what works without Pro. +- **No v4 text:** `whoami`, the login's next steps, the dashboard help and the lines under a dashboard link no + longer say every dashboard is free, or offer Pro to an account that has it. Signed out, `genclass dashboard` in a + folder with an app's private link prints it again. +- **The Pro card:** a used trial is no longer promised; it says which account it is for; n, no, s and exit stop as + q does. `plan` says when a trial ends and its first charge, shows no model runs without Pro, and shows no app + limit for GenClass's grant. A stopped payment wait says so once. +- **For scripts and agents:** `needs_pro` has one JSON shape everywhere: `ok`, `error`, `needs_pro`, `signed_in`, + `plan`, `price`, `trial_days`, `checkout_url`, `run`. `upgrade` without Pro ends like the gated commands (exit 1; + `--json` gives that shape). `scan` and `audit` behind the gate exit 2 (their 1 means problems found) and name the + free page check on genclass.dev. A command's own usage error comes before the gate. +- An app Pro covers is no longer told it "has no plan yet", and its views are not blocked on a terminal. A Free + choice made earlier no longer answers a new plan-page wait. +- **58 columns:** a spinner's line never wraps (the stop hint goes on its own line); a command or an address is never + broken across lines, and warnings are wrapped too; the session file is written with `~`. +- Small ones: `ask` says Pro is needed before its privacy notice; `suggest` remembers "repository only"; adding + GenClass again after `remove` reuses the account's app of the same name (asked first; `--yes` reuses it); the + coding-agent step shows the files it writes and asks; the welcome no longer makes a name from an email address; + `q stop` everywhere; `status` in the home has no card; `analytics` on Free says "already counted" only when it is; + a Pro account's `suggest` with no plan from genclass.dev (`--no-model`) shows every item; a member of a paying team + is told a new app goes into the team's account (genclass.dev's `team_plan` and `via`). + ## 0.3.6 The paid plan is Pro (it was called Team), with 3 days free: diff --git a/packages/cli/README.md b/packages/cli/README.md index 69a5a704..864bed97 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8,7 +8,8 @@ npx genclass ``` Run it in your web app's folder. It shows where the project stands and walks you through the next step: one Enter -per step. Stop at any point and run it again: it continues from what is true now. +per step. Stop at any point and run it again: it continues from what is true now. It comes with Pro: 3 days free, +then US$15 a developer a month. ```text ╭─ GenClass ────────────────────────────────────────────── v0.3.7 ─╮ @@ -25,12 +26,12 @@ per step. Stop at any point and run it again: it continues from what is true now │ ▀▀█▀▀▀ │ │ │ │ shop • Vite + React │ - │ ada@example.com • Free plan │ + │ ada@example.com • Pro plan │ │ ~/code/shop │ │ │ ╰──────────────────────────────────────────────────────────────────╯ - ✔ Done Account • Runtime + ✔ Done Account • Pro • Runtime ● Next Add the test workflow ◌ Waiting Dashboard: deploy, then open your site once ○ Later GitHub App • Coding agent • Suggestions @@ -69,11 +70,13 @@ Type a number, a command, or a sentence: a sentence is a question about your app also a command of its own (`npx genclass problems`), so scripts and coding agents use the same things. **The command comes with Pro.** Right after the sign-in, an account without Pro sees Pro's card, and Enter opens its -checkout in the browser: 3 days free (the card is asked for on Stripe, nothing is charged if you cancel before they -end), then $15 a developer a month, for up to 3 apps. Every command but `login`, `logout`, `whoami`, `plan`, -`upgrade`, `profile`, `status`, `remove`, `help` and `version` asks first. Without a terminal (`--yes`, `--json`, a -coding agent) nothing is opened: one line with the checkout address, and exit code 1. Free stays on the website: one -app and its totals. +checkout in the browser: 3 days free (one trial per account; the card is asked for on Stripe, and nothing is charged +if you cancel before they end), then US$15 a developer a month, for up to 3 apps in the account. More apps come with +Enterprise, by email. Every command but `login`, `logout`, `whoami`, `plan`, `upgrade`, `profile`, `status`, +`remove`, `help` and `version` asks first. Without a terminal (`--yes`, `--json`, a coding agent) nothing is opened: +one line with the checkout address, and exit code 1. Free stays on the website: one app's totals for the last 7 days. +Plans bought before 13 November 2026 keep every app they cover (an app paid for on its own does not count toward +Pro's 3); Free covers an account's oldest app and locks the others. **Just `genclass`.** `npx` runs the command without installing it, so the first run on a terminal installs it (`npm install -g genclass`, said on screen; `npm uninstall -g genclass` takes it out, `GENCLASS_NO_INSTALL=1` turns @@ -94,7 +97,8 @@ it off). After that you type `genclass`, and every screen says `genclass` instea | Suggestions | Tells you what your app is missing, most important first. | Nothing in your project. | On a terminal, Enter starts a step and is the yes for what its sentence says it writes. While a step waits for you -in the browser (the sign-in, GitHub), Enter stops the wait and the walk goes on. A part that does not apply +in the browser (the sign-in, GitHub), Enter stops the wait and the walk goes on. Pro and the app's plan are the +exception: nothing after them runs until they are done. A part that does not apply is left out and says why: no Playwright tests, not a GitHub repository, not a web app. ## Your app in the terminal @@ -109,7 +113,7 @@ suggestions run left to do. ```text GenClass • shop Vite + React - ada@example.com • Team plan + ada@example.com • Pro plan ──────────────────────────────────────────────────────────────────────────── last 7 days · last page 3 hours ago @@ -140,11 +144,11 @@ Each part of the browser dashboard has its own view, short first, `--all` for ev | | | |---|---| | `npx genclass problems` | what was found, by kind and by page, the measured visual checks, what GenClass stopped on the visitor's screen, how it compares with the period before | -| `npx genclass analytics` | visitors: visits, page views, time on a page, where people come from, devices and countries, what gets clicked again and again. One part in full: `analytics pages`, `sources`, `devices`, `clicks`, `paths`, `funnels` (Team) | +| `npx genclass analytics` | visitors: visits, page views, time on a page, where people come from, devices and countries, what gets clicked again and again. One part in full: `analytics pages`, `sources`, `devices`, `clicks`, `paths`, `funnels` | | `npx genclass events` | the latest events one per line: when, kind, how sure the model was, the page, and what GenClass did | | `npx genclass health` | how GenClass itself runs on your pages: model loads, where it ran, decision time, what was not checked, runtime versions | | `npx genclass report` | the period as Markdown, to paste or pipe (`--out report.md` writes a file) | -| `npx genclass export` | every stored event as a file, JSON Lines or `--format csv`, `--from` and `--to` as days (Team) | +| `npx genclass export` | every stored event as a file, JSON Lines or `--format csv`, `--from` and `--to` as days | ## Have it fixed @@ -185,22 +189,24 @@ npx genclass connections Where your app's new problems are sent (Slack, Sentry, PostHog, Datadog, or a webhook of your own), what the GitHub App is doing on the repository, whether your coding agent is connected, and whether the app has a private link. -- `npx genclass connections add slack` sets a destination up (Team). What you type is not shown, goes to +- `npx genclass connections add slack` sets a destination up. What you type is not shown, goes to genclass.dev over HTTPS, is stored encrypted there and is not kept on your machine. Then `test`, `off`, `on`, `remove` in place of `add`. - `npx genclass agent` shows how your coding agent is connected and what it did with the findings (found, reproduced, fix proven, closed). `npx genclass agent link` gives it the live site's numbers: it makes the app's private dashboard link and hands it to GenClass's MCP server in your own agent settings, never in a file of the repository. -- `npx genclass funnels add "Checkout" /cart /checkout /thanks` adds a funnel (Team); `funnels remove "Checkout"` +- `npx genclass funnels add "Checkout" /cart /checkout /thanks` adds a funnel; `funnels remove "Checkout"` removes it. ## Account `npx genclass apps` lists your apps (`apps new "Name"` makes one). `npx genclass plan` shows your plan, its limits and what is used, and each app's plan. `npx genclass profile` shows your account and each app's plan with numbered -choices (upgrade, billing, sign out); `npx genclass upgrade` opens the plan page of this folder's app. `npx genclass team` shows who is on -the plan (`team invite` makes a single-use invite link). +choices (upgrade, billing, sign out). `npx genclass upgrade` starts Pro, its free trial first, for an account +without it. With Pro there is nothing to upgrade: it says where seats are added (Manage billing), and for an app past +Pro's 3 it says that more apps come with Enterprise, with the app's plan page. `npx genclass team` shows who is on the +plan (`team invite` makes a single-use invite link). **What a terminal may do** is listed on the page where you approve its sign-in, and nothing else: read your apps' dashboards, analytics and stored events, ask the model, change where alerts go, funnels and the private link, show @@ -227,12 +233,13 @@ It measures your repository and your live site against a list of checks, in plai - **Email:** SPF and DMARC, so your mail does not land in spam. Each failed check comes with the evidence that was measured, what to do, and a prompt you can paste to your coding -agent. When you are signed in, genclass.dev orders the list for your app and words the reasons for it. +agent. genclass.dev orders the list for your app and words the reasons for it. **What leaves your machine.** Requests to your own site, and, to get the plan, the checks that failed with their evidence lines, plus your app's name, framework, address, payment provider and host. No source code, no file contents, no secrets. The command shows this before the first time it sends it for a project; `--no-model` sends -nothing. genclass.dev passes it to the model that writes the plan and stores none of it. +nothing. genclass.dev passes it to the model that writes the plan, and keeps the plan it writes for your app, not +what was sent. **What Pro shows.** Every check, with its evidence, and the plan for all of them: why it matters for your app, the first step and the agent prompt, with the model's own ideas and website designs. @@ -257,12 +264,12 @@ first step and the agent prompt, with the model's own ideas and website designs. | `npx genclass fix [number, kind or all]` | hand a problem to your coding agent (`--print`, `--copy`, `--agent `) | | `npx genclass ask "..."` | a question about your app, answered from its numbers | | `npx genclass suggest [url]` | what your app is missing, then one number hands the list to your coding agent (`--yes`, `--no-model`, `--all`) | -| `npx genclass scan ` | 23 visual checks on one page | +| `npx genclass scan ` | 23 visual checks on one page (free, with no account: `npx @genclass/runtime@beta scan `) | | `npx genclass connections` | alerts, GitHub App, coding agent (`add`, `test`, `on`, `off`, `remove` a destination) | | `npx genclass agent` | your coding agent and what it did (`agent link`) | | `npx genclass funnels` | funnels (`add "Name" /a /b`, `remove "Name"`) | | `npx genclass apps`, `plan`, `team` | your apps, your plan, the people on it | -| `npx genclass profile`, `upgrade` | your account and each app's plan; Team for this app | +| `npx genclass profile`, `upgrade` | your account and each app's plan; start Pro | | `npx genclass login`, `logout`, `whoami` | your account | | `npx genclass remove` | take GenClass out of the app again | @@ -282,9 +289,14 @@ npx genclass fix --print # the prompt to fix what was found (fix stale --p npx genclass report # the period as Markdown ``` -Two steps need the person: approving the sign-in and installing the GitHub App. Both happen in their browser, and -the command prints the link. At the sign-in, `setup --yes` prints a link and a code and waits up to ten minutes for -the person to approve, so give it a long timeout, or have them run `npx genclass login` first. +Four steps need the person, in their browser, and the command prints the link for each: approving the sign-in, +starting Pro's 3-day trial (their card, on Stripe), opening a new app's plan page once, and installing the GitHub +App. At the sign-in, `setup --yes` prints a link and a code and waits up to ten minutes for the person to approve, so +give it a long timeout, or have them run `npx genclass login` first. Without Pro, `setup --yes` stops at the trial +with exit code 1 and one line with the checkout link, and every command that needs Pro answers the same way (`upgrade` +included; `scan` and `audit` end with 2): give the person the link, wait for them to start the trial, then run +`setup --yes` again. With `--json` that answer is one shape everywhere: `{ "ok": false, "needs_pro": true, +"signed_in", "plan", "price", "trial_days", "checkout_url", "run" }`, where `run` is the command to run next. ## How it is built diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index b4745aa3..0aa6b2e0 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -44,8 +44,6 @@ const nap = (ms, signal) => { once: true }, ); }); -/** What a wait's line ends with on a terminal: the person is never held by one. */ -const canStop = () => (interactive() ? c.gray(" (Enter to stop waiting)") : ""); /** Opens `url` in the default browser when that makes sense here. Returns whether it tried; never throws. */ export function openBrowser(url, o = {}) { @@ -83,7 +81,7 @@ export async function signIn(o = {}) { out(); if (openBrowser(dev.verification_uri_complete, o)) note("Your browser is opening that page."); const stop = stopOnEnter(); - const sp = spinner(`Waiting for you to approve in the browser${canStop()}`); + const sp = spinner("Waiting for you to approve in the browser", { stoppable: interactive() }); const r = await pollDeviceLogin(dev, { signal: stop.signal }); await stop.done(); if (!r.ok && r.error === "cancelled") { @@ -136,7 +134,7 @@ export function upgradeOffer(state, name = null) { const here = mine.find((p) => p.token === state.app?.token) ?? null; const target = named ?? here ?? mine.find((p) => p.needsPlan) ?? mine.find((p) => p.plan.id === "free" && !p.plan.source) ?? null; if (!target || target.plan.id !== "free" || target.plan.source || !target.planUrl) return null; - return { ...base, checkoutUrl: `${target.planUrl}&from=cli`, project: { id: target.id, name: target.name, token: target.token }, needs: target.needsPlan === true }; + return { ...base, checkoutUrl: `${target.planUrl}&from=cli`, project: { id: target.id, name: target.name, token: target.token }, needs: target.needsPlan === true, chosen: target.planChosen === true, paying: accountPaid(state.account) }; } /** @@ -144,7 +142,9 @@ export function upgradeOffer(state, name = null) { * rule (2026-11-12) is that it has its plan chosen before anything else, Pro or Free with a reason, so this opens its * plan page in the browser and waits for the choice. `id` names the app; without it, this folder's. Without a * terminal it says where the page is and goes on. Returns "chosen", "none" (nothing to choose: a plan, a grant, a - * choice already, or nothing sold) or "open" (the choice was not made). + * choice already, or nothing sold), "covered" (the account's plan covers it; Continue was not clicked yet, which + * blocks nothing), "locked" (past the apps a paying account's plan covers: Enterprise, by email) or "open" (the + * choice was not made). */ export async function planNow(o = {}, deps = {}, id = null) { let state; @@ -158,13 +158,20 @@ export async function planNow(o = {}, deps = {}, id = null) { // there), and an app its plan does not cover waits there (state.mjs appPlanOffer) const offer = appPlanOffer(app, state.account?.offer ?? null, !!state.account && accountPaid(state.account)); if (!offer) return "none"; - if (o.json) return "open"; + // past the apps a paying account's plan covers: nothing to pay for here, more apps are Enterprise's (by email) + if (offer.needs && offer.paying && !offer.appsPerUnit) { + if (!o.json) await projectPlan(offer, o, deps); + return "locked"; + } + if (o.json) return offer.current ? "covered" : "open"; if (o.yes || !interactive()) { out(); - note(`${clean(app.name, 40)} has no plan yet. Choose it in the browser: ${offer.checkoutUrl}`); - return "open"; + // an app the account's plan covers has a plan: it goes on with it once, on its page + note(offer.current ? `${clean(app.name, 40)} is covered by your ${clean(offer.current, 20)} plan. Continue with it once on its plan page: ${offer.checkoutUrl}` : `${clean(app.name, 40)} has no plan yet. Choose it in the browser: ${offer.checkoutUrl}`); + return offer.current ? "covered" : "open"; } - return (await projectPlan(offer, o, deps)) === "done" ? "chosen" : "open"; + const r = await projectPlan(offer, o, deps); + return r === "done" ? "chosen" : offer.current ? "covered" : "open"; } /** @@ -188,15 +195,23 @@ export async function checkout(offer, o = {}, deps = {}) { */ export async function projectPlan(offer, o = {}, deps = {}) { const name = clean(offer.project.name, 30); + // a paying account past the apps its plan covers: its page sells nothing (more apps are Enterprise's, by email), + // so nothing is opened or waited for: the line, where the address is, and the setup goes on + if (offer.needs && offer.paying && !offer.appsPerUnit) { + out(); + warn(`Your plan covers no more apps, and ${name} is one more. More apps come with Enterprise, by email.`); + out(` ${c.gray("The address, on its plan page:")}`); + out(` ${c.cyan(offer.checkoutUrl)}`); + note("Or delete an app you no longer need, in the browser."); + return "skipped"; + } if (offer.features?.length) planCard(offer, offer.project.name); else out(); note( offer.current ? `Your ${clean(offer.current, 20)} plan covers ${name}: continue with it on its plan page.` : offer.needs && offer.paying - ? offer.appsPerUnit - ? `Your plan covers no more apps: add ${offer.appsPerUnit} more for ${name} on its plan page.` - : `Your plan covers no more apps, and ${name} is one more. More apps come with Enterprise, by email: its plan page has the address.` + ? `Your plan covers no more apps: add ${offer.appsPerUnit} more for ${name} on its plan page.` : offer.needs ? `Free covers ${offer.free.apps === 1 ? "one app" : `${offer.free.apps ?? 1} apps`} and this is one more: it needs ${offer.plan.name}, ${offer.price}.` : `${offer.plan.name} is ${offer.price}. Or try ${offer.free.name.toLowerCase()} for now (limited), on the same page.`, @@ -226,7 +241,7 @@ async function waitForPlan(offer, o = {}, deps = {}) { if (openBrowser(offer.checkoutUrl, o)) note(app ? `Your browser is opening the plan page for ${app}.` : "Your browser is opening the checkout."); const creds = readCredentials(); const stop = stopOnEnter(); - const sp = spinner(`${offer.current || (app && !offer.needs) ? "Waiting for your choice in the browser" : "Waiting for the payment in the browser"}${canStop()}`); + const sp = spinner(offer.current || (app && !offer.needs) ? "Waiting for your choice in the browser" : "Waiting for the payment in the browser", { stoppable: interactive() }); const until = Date.now() + (deps.waitMs ?? 10 * 60_000); let got = null; while (creds && !stop.signal.aborted && Date.now() < until) { @@ -239,7 +254,8 @@ async function waitForPlan(offer, o = {}, deps = {}) { // covered already: Continue was clicked; else a payment (or more apps) that covers it now, or Free chosen if (offer.current) got = p?.planChosen ? "kept" : null; else if (p?.plan && (p.plan.id !== "free" || p.plan.source) && !p.needsPlan) got = "paid"; - else if (p?.planChosen && !offer.needs && !p.needsPlan) got = "free"; + // Free counts only when it is chosen now: a choice made before this wait is not an answer to it + else if (p?.planChosen && !offer.chosen && !offer.needs && !p.needsPlan) got = "free"; } else if (me.cli ? me.cli.paid : me.plan && me.plan.id !== "free") got = "paid"; if (got) break; } @@ -247,7 +263,7 @@ async function waitForPlan(offer, o = {}, deps = {}) { if (got === "kept") sp.stop("ok", `${app} goes on with ${clean(offer.current, 20)}.`); else if (got === "paid") sp.stop("ok", `${offer.plan.name} is active${app ? ` for ${app}` : ""}. Thank you.`); else if (got === "free") sp.stop("ok", `${app} is on ${offer.free.name} for now (limited).`); - else sp.stop("warn", app && !offer.needs ? "No choice yet." : `No payment yet. ${offer.plan.name} starts the moment the payment goes through.`); + else sp.stop("warn", app && !offer.needs ? "No choice yet." : `No payment yet. ${offer.plan.name} starts once it is started on Stripe.`); return got; } @@ -275,9 +291,13 @@ export function runtimeArgs(state, o = {}) { * missing (the step says so), only they are written. */ export async function addRuntime(state, o = {}) { + // a member of a paying team: init and connect say the new app goes into the team's account + if (state.account) (await import("./paywall.mjs")).noteAccount(state.account); if (!state.steps.find((s) => s.id === "runtime")?.vite) { const { cmd, args } = runtimeArgs(state, o); - const code = cmd === "connect" ? await runLogin("connect", args) : await runInit("init", args); + // GenClass taken out and added again: the account's app of the same name is this one, not a new app + const again = cmd === "init" ? await sameApp(state, o) : null; + 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"; @@ -285,6 +305,35 @@ export async function addRuntime(state, o = {}) { return (await addViteSettings(state, o)) === "failed" ? "failed" : "done"; } +/** + * A fresh project (no GenClass in it) whose name is an app already in the account, as after `genclass remove`: on a + * terminal it asks whether this is that app (yes by default), with --yes it is. Returns the init that writes that + * app's key (no new app is made), or null when there is no such app or the person said no. + */ +export async function sameApp(state, o = {}) { + if (!state.signedIn) return null; + let st; + try { + st = inspectProject({ cwd: state.cwd }); + } catch { + return null; + } + const name = String(st.project.pkg?.name || st.project.name || "").slice(0, 80); + const app = name ? (state.projects ?? []).find((p) => !p.shared && p.name === name && p.token) : null; + if (!app) return null; + if (!o.yes) { + if (!interactive()) return null; + out(); + if (!(await agree(`Use your existing ${clean(app.name, 40)}? ${c.gray("(No makes a new app)")}`))) return null; + } + const { args } = runtimeArgs(state, o); + const creds = readCredentials(); + return async () => { + const { init, parse } = await import("../../runtime/bin/lib/init.mjs"); + return init({ ...parse(args), token: app.token, ...(o.noInstall ? { noInstall: true } : {}) }, { cmd: "init", creds, app }); + }; +} + /** Shows the two Vite settings and writes them on a yes (--yes, or one question on a terminal). */ export async function addViteSettings(state, o = {}) { let plan = null; @@ -341,7 +390,7 @@ export async function watchFirstPage(state, o = {}) { } } else return "skipped"; const stop = stopOnEnter(); - const sp = spinner(`Open your live site in a browser now. Watching for its first page${canStop()}`); + const sp = spinner("Open your live site in a browser now. Watching for its first page", { stoppable: interactive() }); const until = Date.now() + (o.seconds ?? 120) * 1000; while (Date.now() < until && !stop.signal.aborted) { await nap(5000, stop.signal); @@ -410,7 +459,7 @@ export async function addWorkflow(state, o = {}) { */ async function waitForGithub(creds, repo, text, until, seconds) { const stop = stopOnEnter(); - const sp = spinner(`${text}${canStop()}`); + const sp = spinner(text, { stoppable: interactive() }); const end = Date.now() + seconds * 1000; let last = null; while (Date.now() < end && !stop.signal.aborted) { @@ -555,14 +604,17 @@ export async function connectAgent(state, o = {}) { out(` ${c.cyan(`"genclass": ${JSON.stringify(mcpServer())}`)}`); return "skipped"; } - if (!o.yes && !o.confirmed) { + // what is written is shown and asked first, as the runtime step's change is (in the walk too); --yes writes it + if (!o.yes) { out(); printChange(plan.change); + out(` ${c.gray(`And ${SKILL_PATH}: how the agent uses GenClass's tools (a file you changed is left alone).`)}`); + out(); if (!interactive()) { note(`Not a terminal: run ${c.cyan("npx genclass setup --yes")} to write it.`); return "skipped"; } - if (!(await agree("Write this change?"))) return "skipped"; + if (!(await agree(`Write ${plan.file} and the skill?`))) return "skipped"; } try { mkdirSync(dirname(plan.path), { recursive: true }); diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs index b2ebadf6..16d4dae2 100644 --- a/packages/cli/src/api.mjs +++ b/packages/cli/src/api.mjs @@ -37,14 +37,17 @@ export async function fetchOverview(creds, opts = {}) { if (!r.ok) return { ok: false, status: r.status, reason: failure(r) }; const j = r.json ?? {}; const projects = (Array.isArray(j.projects) ? j.projects : []).map((p) => withPlan(cleanProject(p, creds.host), p, creds.host)).filter(Boolean); - const account = j.account && typeof j.account === "object" ? { id: String(j.account.id ?? ""), email: str(j.account.email), github_login: str(j.account.github_login, 60) } : creds.account; + // team_plan (a newer server): the plan of a paying team the account is in, whose account its new apps go into + const tp = j.account?.team_plan; + const account = j.account && typeof j.account === "object" ? { id: String(j.account.id ?? ""), email: str(j.account.email), github_login: str(j.account.github_login, 60), ...(tp && typeof tp === "object" && str(tp.id, 40) ? { teamPlan: { id: str(tp.id, 40), name: str(tp.name, 40) ?? str(tp.id, 40) } } : {}) } : creds.account; if (!full) return { ok: true, account, plan: null, suggestions: null, projects, github: null, billingUrl: null }; const s = j.suggestions && typeof j.suggestions === "object" ? j.suggestions : null; const num = (v) => (Number.isFinite(Number(v)) && v !== null ? Number(v) : null); return { ok: true, account, - 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) } : null, + // `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, 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, @@ -59,11 +62,13 @@ export async function fetchOverview(creds, opts = {}) { * place in a paying team) and, without it, where Pro starts: the plan's name, its price, the trial the account has * left, and the page that opens the checkout (on the server's own host; its billing page when it sent another). * Null when the server does not say (an older or self-hosted one): nothing is locked then. - * @returns {{ paid: true } | { paid: false, plan: string, price: string | null, trialDays: number | null, checkoutUrl: string } | null} + * `via` (a newer server): why the account has it, "plan" (its own), "team" (a paying team's) or "open" (nothing is + * sold here); null when the server does not say. + * @returns {{ paid: true, via: "plan" | "team" | "open" | null } | { paid: false, plan: string, price: string | null, trialDays: number | null, checkoutUrl: string } | null} */ export function cliOf(v, host) { if (!v || typeof v !== "object" || typeof v.paid !== "boolean") return null; - if (v.paid) return { paid: true }; + if (v.paid) return { paid: true, via: ["plan", "team", "open"].includes(v.via) ? v.via : null }; const days = Number(v.trial_days); return { paid: false, @@ -368,6 +373,8 @@ 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, }; } diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index 639535b4..05c44065 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -242,7 +242,7 @@ export async function pickApp(o = {}, deps = {}, name = "dashboard") { note(wanted.length > 1 ? `More than one app is named "${clean(o.app, 40)}". Which one?` : o.app ? `No app named "${clean(o.app, 40)}". Which one?` : state.app.token ? "This folder's app is not in your account yet. Which app?" : "Which app?"); list.forEach((p, i) => out(` ${c.gray(String(i + 1).padStart(2))} ${c.bold(clean(appLabel(p, projects), 34))}${p.last_event ? c.gray(` last page ${ago(p.last_event, deps.now)}`) : ""}`)); out(); - const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("back")} `); + const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("stop")} `); const i = a !== null && /^\d+$/.test(a.trim()) ? Number(a.trim()) - 1 : -1; if (list[i]) return { state, creds, target: list[i] }; out(); @@ -277,9 +277,15 @@ export async function appView(o = {}, deps = {}, view) { if ("code" in picked) return picked.code; let { state } = picked; const { creds, target } = picked; - // an app of the account with no plan chosen opens its plan page first, as its page on genclass.dev does + // an app of the account with no plan chosen opens its plan page first, as its page on genclass.dev does. An app + // 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); + if (p === "locked") { + out(); + return 1; + } if (p === "open") { out(); note(`${clean(target.name, 40)} needs its plan first. Run this again once it is chosen.`); @@ -294,7 +300,7 @@ export async function appView(o = {}, deps = {}, view) { // signed in before the command could show dashboards: one more approval in the browser, then it is shown header({ app: target.name, framework: state.app.framework, account: state.account?.label, plan: planName(state, target.token), host: state.host }); note("This sign-in is from before the command could show dashboards, so it was not approved for that. Signing in once more fixes it."); - const a = await typed(` ${c.bold("Enter")} ${c.gray("to sign in again,")} ${c.bold("q")} ${c.gray("to stop")} `); + const a = await typed(` ${c.bold("Enter")} ${c.gray("to sign in again ·")} ${c.bold("q")} ${c.gray("stop")} `); if (a === null || /^q/i.test(a)) return 1; if ((await signIn(o)) !== "done") return 1; const again = readCredentialsFor().creds; diff --git a/packages/cli/src/ask.mjs b/packages/cli/src/ask.mjs index aba2615e..04539898 100644 --- a/packages/cli/src/ask.mjs +++ b/packages/cli/src/ask.mjs @@ -82,6 +82,9 @@ export async function ask(o = {}, deps = {}) { const { state, creds, target } = picked; const range = ["24h", "7d", "30d", "90d", "1y"].includes(o.range) ? o.range : "7d"; if (!o.json) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: planName(state, target.token), host: state.host }); + // an account without Pro is told so before the notice: it is not asked to agree to something that cannot run + const cli = state.account?.cli ?? null; + if (cli && !cli.paid) return refused({ ok: false, needsPro: cli }, { what: "A question to the model", command: "ask", json: o.json }); // once per machine: what is sent, and where const prefs = (deps.readPrefs ?? readPrefs)(); if (!prefs.askTold) { diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 132fa535..3ad21ad6 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -4,14 +4,19 @@ // Invariants: // - One way in: `genclass` with no command shows where the project stands and, on a terminal, walks the next step. // Every other command is a part of that walk that can also be run alone. -// - `login`, `logout`, `whoami`, `connect`, `init` and `scan` are the runtime CLI's own commands, unchanged: this -// file hands them their arguments. `remove` is the runtime CLI's, followed by what this command's other steps wrote -// (remove.mjs). `dashboard` is this command's own view (app.mjs); signed out it falls back to the runtime CLI's, -// which knows an app's private dashboard link. +// - `login`, `logout`, `whoami`, `connect`, `init` and `scan` are the runtime CLI's own commands: this file hands +// them their arguments. `genclass login` only signs in (the runtime login with --no-connect), then shows Pro for an +// account without it, and offers to connect this folder's app once Pro is on. `remove` is the runtime CLI's, +// followed by what this command's other steps wrote (remove.mjs). `dashboard` is this command's own view +// (app.mjs); signed out, in a folder with an app's private link, it prints that link (the runtime CLI's own). // - The command is Pro's (the owner's call, 2026-11-13): every command but FREE_COMMANDS passes paywall.mjs -// requirePro first (signed out: the sign-in, then Pro with its free trial). The walk (`genclass`, `setup`) has Pro -// as its own step right after the sign-in (state.mjs), and a command run from the home (ctx.nested) came after it. -// - Nothing here reads a file or the network but that gate: it parses and dispatches. +// requirePro first (signed out: the sign-in, then Pro with its free trial), the home's commands (ctx.nested) +// included. The walk (`genclass`, `setup`) has Pro as its own step right after the sign-in (state.mjs). +// - Every opt-out keeps working without Pro (AGENTS.md rule 8): `init --no-telemetry` and `init --telemetry` on an +// app that has GenClass change only that option, and pass without the gate. +// - A command's own usage errors come before the gate: nothing is asked of genclass.dev for words that are wrong. +// - Nothing here reads the network but that gate, and nothing reads a file but the two checks before it (whether +// the app has GenClass, for the opt-out; its private dashboard link, signed out): it parses and dispatches. import { hostConfig } from "../../runtime/bin/lib/host.mjs"; import { c, err, interactive, out } from "../../runtime/bin/lib/ui.mjs"; @@ -19,6 +24,10 @@ import { BUILD } from "./assets.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) @@ -50,7 +59,9 @@ export const USAGE = `genclass: GenClass in your terminal. Set it up, see what i Account npx genclass apps your apps (apps new "Name") - npx genclass plan your plan, its limits and what is used; start the paid plan (--open) + 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 @@ -92,10 +103,16 @@ export async function run(argv, ctx = {}) { // the genclass command is Pro's: a command asks genclass.dev first (paywall.mjs requirePro), once its words are // known to be right. Not: signing in and out, the plan, help, the version, status (where things stand, Pro's step - // included), remove, a command run from the home, and the walk, whose own Pro step comes right after the sign-in + // included), remove, and the walk, whose own Pro step comes right after the sign-in. A command typed in the home + // asks too: the home opens on q at any step, the sign-in's included (once Pro is seen, it is not asked again) const asksHelp = args.some((a) => ["--help", "-h", "--version", "-v"].includes(a)); const paid = async () => { - if (ctx.nested || asksHelp || FREE_COMMANDS.includes(cmd) || hostConfig().error) return 0; + if (asksHelp || FREE_COMMANDS.includes(cmd)) return 0; + const h = hostConfig(); + if (h.error) { + err(`genclass: ${h.error}`); + return 2; + } return (await import("./paywall.mjs")).requirePro(parseArgs(args).o); }; @@ -106,22 +123,49 @@ export async function run(argv, ctx = {}) { const again = await (await import("./app.mjs")).signInAgain(parseArgs(args).o); if (again !== null) return again; } - // Pro comes right after the sign-in (paywall.mjs afterLogin), and an app that login, connect or init put in the - // account opens its plan page next (actions.mjs planNow) + // an app that login, connect or init put in the account opens its plan page next (actions.mjs planNow) + const i = args.indexOf("--cwd"); + const po = { cwd: i >= 0 ? args[i + 1] : undefined, yes: args.includes("--yes") || args.includes("-y"), json: args.includes("--json"), noBrowser: args.includes("--no-browser") }; const thenPlan = async (code) => { - if (code !== 0 || ctx.inWalk) return code; - const i = args.indexOf("--cwd"); - const po = { cwd: i >= 0 ? args[i + 1] : undefined, yes: args.includes("--yes"), json: args.includes("--json"), noBrowser: args.includes("--no-browser") }; - if (cmd === "login" && !(await (await import("./paywall.mjs")).afterLogin(po).catch(() => true))) return code; + if (code !== 0) return code; await (await import("./actions.mjs")).planNow(po).catch(() => "none"); return code; }; if (["logout", "whoami"].includes(cmd)) return (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args); - if (cmd === "login") return thenPlan(await (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args)); + if (cmd === "login") { + // the sign-in only; then Pro (paywall.mjs afterLogin) for an account without it; this folder's app is offered + // to the account (the runtime login's own question) only once Pro is on, so nothing is written before Pro's card + const login = await import("../../runtime/bin/lib/login.mjs"); + const noConnect = args.includes("--no-connect"); + const code = await login.run(cmd, noConnect || asksHelp ? args : [...args, "--no-connect"]); + if (code !== 0 || asksHelp) return code; + if (!(await (await import("./paywall.mjs")).afterLogin(po).catch(() => true))) return code; + return thenPlan(noConnect ? code : await login.offerConnect(args).catch(() => 1)); + } if (cmd === "remove") return (await import("./remove.mjs")).remove(args); if (["connect", "init", "scan", "audit"].includes(cmd)) { - const gate = await paid(); - if (gate !== 0) return gate; + // words the command does not take: its own usage error, before anything is asked of genclass.dev + if (!asksHelp && !(await argsRight(cmd, args))) { + if (cmd === "connect") return (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args); + if (cmd === "init") return (await import("../../runtime/bin/lib/init.mjs")).run(cmd, args); + return (await import("../../runtime/bin/lib/scan.mjs")).run(cmd, args); + } + // the opt-out the setup prints works for everyone (AGENTS.md rule 8): on an app with GenClass in it, init + // --no-telemetry (or --telemetry, to undo it) changes that one option, here on this machine + const optOut = cmd === "init" && telemetryOnly(args) && !(await import("../../runtime/bin/lib/init.mjs")).inspectProject({ cwd: po.cwd }).fresh; + const gate = optOut ? 0 : await paid(); + if (gate !== 0) { + if (cmd !== "scan" && cmd !== "audit") return gate; + // a scan's exit code 1 means "problems found": a scan that did not run ends with 2, and says where the page + // check is free + if (!po.json && gate === 1) { + const { REPORT_PAGE } = await import("../../runtime/bin/lib/scan.mjs"); + out(` ${c.gray("Free, without Pro: the page check on the website")}`); + out(` ${c.cyan(REPORT_PAGE)}`); + out(); + } + return 2; + } } if (cmd === "connect") return thenPlan(await (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args)); if (cmd === "init") return thenPlan(await (await import("../../runtime/bin/lib/init.mjs")).run(cmd, args)); @@ -197,15 +241,15 @@ export async function run(argv, ctx = {}) { err(`genclass agent: there is no ${c.bold(words[0])}. Try ${c.cyan("npx genclass agent")} or ${c.cyan("npx genclass agent link")}`); return 2; } + // signed out, the runtime CLI's own command still knows an app's private dashboard link (.genclass.local): it is + // printed as before, with no sign-in and no Pro (it is on this machine already) + const fallback = async () => (await import("../../runtime/bin/lib/login.mjs")).run("dashboard", [...(o.cwd ? ["--cwd", o.cwd] : []), ...(o.open ? ["--open"] : [])]); + if (cmd === "dashboard" && !o.json && !o.app && (await privateLinkHere(o.cwd))) return fallback(); if (cmd !== null && !["setup", "status"].includes(cmd)) { const gate = await paid(); if (gate !== 0) return gate; } - if (cmd === "dashboard") { - // signed out, the runtime CLI's own command still knows an app's private dashboard link - const fallback = async () => (await import("../../runtime/bin/lib/login.mjs")).run("dashboard", [...(o.cwd ? ["--cwd", o.cwd] : []), ...(o.open ? ["--open"] : [])]); - return upsold((await import("./app.mjs")).dashboard(o, { fallback })); - } + if (cmd === "dashboard") return upsold((await import("./app.mjs")).dashboard(o, { fallback })); if (["problems", "events", "health", "report"].includes(cmd)) return upsold((await import("./views.mjs"))[cmd](o), cmd !== "report"); if (cmd === "analytics") return upsold((await import("./views.mjs")).analytics({ ...o, section: words[0] ?? null })); if (cmd === "agent") { @@ -228,6 +272,64 @@ export async function run(argv, ctx = {}) { return 2; } +/** init's options that leave what it does at the telemetry switch: where, whether to ask, and what not to run. */ +const TOGGLE_WITH = new Set(["--yes", "-y", "--dry-run", "--no-install", "--no-network"]); + +/** Whether `init`'s arguments only turn the anonymous diagnostics off (--no-telemetry) or back on (--telemetry). */ +export function telemetryOnly(args) { + let toggle = false; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--cwd") i++; + else if (a.startsWith("--cwd=")) continue; + else if (a === "--no-telemetry" || a === "--telemetry") toggle = true; + else if (!TOGGLE_WITH.has(a)) return false; + } + return toggle; +} + +/** Whether a runtime command's words are ones it takes (a parse only: nothing is read or sent). */ +async function argsRight(cmd, args) { + try { + if (cmd === "init") { + const { parse } = await import("../../runtime/bin/lib/init.mjs"); + parse(args); + return true; + } + if (cmd === "connect") return (await import("../../runtime/bin/lib/login.mjs")).argsError("connect", args) === null; + // scan and audit: their flags, and one address that is a URL + const { normaliseUrl } = await import("../../runtime/bin/lib/scan.mjs"); + const urls = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (["--json", "--phone", "--hosted", "--local"].includes(a) || /^--(timeout|cwd)=./.test(a)) continue; + if (a === "--timeout" || a === "--cwd") { + if (args[++i] === undefined) return false; + continue; + } + if (a.startsWith("-")) return false; + urls.push(a); + } + if (urls.length !== 1) return false; + normaliseUrl(urls[0]); + return true; + } catch { + return false; + } +} + +/** Signed out, in a folder whose app has a private dashboard link (init without an account wrote it). */ +async function privateLinkHere(cwd) { + try { + const { readCredentialsFor } = await import("../../runtime/bin/lib/account.mjs"); + if (readCredentialsFor().creds) return false; + const st = (await import("../../runtime/bin/lib/init.mjs")).inspectProject({ cwd }); + return !!st.local?.dashboardUrl && (!st.token || st.local.token === st.token); + } catch { + return false; + } +} + /** The commands that work without Pro: signing in and out, the plan itself, where things stand, help, the version, and taking GenClass out. */ export const FREE_COMMANDS = ["login", "logout", "whoami", "plan", "upgrade", "profile", "status", "remove", "help", "version"]; diff --git a/packages/cli/src/home.mjs b/packages/cli/src/home.mjs index 1747a7e8..272c4c68 100644 --- a/packages/cli/src/home.mjs +++ b/packages/cli/src/home.mjs @@ -36,7 +36,7 @@ export const COMMANDS = [ ["scan ", "check one page", false], ["apps", "the apps in your account", false], ["plan", "your plan and what it includes", false], - ["upgrade", "a paid plan for this app", false], + ["upgrade", "start Pro, or see what your plan has", false], ["team", "the people on your plan", false], ["status", "where the setup stands", false], ["setup", "walk the setup again", false], diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index 36cddd70..7d961b6f 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -82,12 +82,18 @@ export async function apps(o = {}, deps = {}) { // plans are per project: an app past the ones Free covers needs its own, and its page says so const me = await fetchOverview(creds, deps).catch(() => null); const made = me?.ok ? me.projects.find((p) => p.token === r.token) : null; + const full = !!made?.needsPlan && accountPaid(me); if (made?.needsPlan && made.planUrl) { - warn(accountPaid(me) ? "Your plan covers no more apps: more come with Enterprise, by email (its plan page has the address)." : "Free covers one app: this one needs Pro."); - out(` ${c.gray("Its plan:")} ${c.cyan(made.planUrl)}`); - } else if (made?.plan?.id === "free" && made.planUrl) out(` ${c.gray("On Free (limited). Its plan:")} ${c.cyan(made.planUrl)}`); + // past the apps a paying account's plan covers: nothing to pay for, so nothing is opened or waited for + warn(full ? "Your plan covers no more apps: more come with Enterprise, by email." : "Free covers one app: this one needs Pro."); + out(` ${c.gray(full ? "The address, on its plan page:" : "Its plan:")}`); + out(` ${c.cyan(made.planUrl)}`); + } else if (made?.plan?.id === "free" && made.planUrl) { + out(` ${c.gray("On Free (limited). Its plan:")}`); + out(` ${c.cyan(made.planUrl)}`); + } // its plan is chosen now, on its page (Pro, or Free with a reason) - if (made?.id) await planNow({ cwd: o.cwd }, deps, made.id); + if (made?.id && !full) await planNow({ cwd: o.cwd }, deps, made.id); note("To put GenClass into that app's code, run npx genclass in its folder and pick this app."); } out(); @@ -156,12 +162,20 @@ export async function plan(o = {}, deps = {}) { 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 { 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") : ""}`); - if (a.subscription.periodEnd && a.access === "paid") row(a.subscription.cancels ? "Ends" : "Renews", day(a.subscription.periodEnd)); + // 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)}`); + 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.`); + else if (a.subscription.periodEnd && a.access === "paid") row(a.subscription.cancels ? "Ends" : "Renews", day(a.subscription.periodEnd)); if (a.subscription.status && !["active", "trialing"].includes(a.subscription.status)) row("Payment", c.yellow(clean(a.subscription.status.replace(/_/g, " "), 30))); - row("Apps", a.limits.apps === null ? `${n(a.used.apps)} ${sym.dot} no limit` : `${n(a.used.apps)} of ${n(a.limits.apps)}`); + // GenClass's grants cover every app, whatever the plan's own number says + row("Apps", a.limits.apps === null || a.access === "comp" ? `${n(a.used.apps)} ${sym.dot} no limit` : `${n(a.used.apps)} of ${n(a.limits.apps)}`); row("History", a.limits.historyDays === null ? "" : `${n(a.limits.historyDays)} days`); row("People", a.limits.seats === null ? `${n(a.used.seats)} ${sym.dot} no limit` : `${n(a.used.seats)} of ${n(a.limits.seats)}`); - 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`); + // without Pro neither suggest nor ask runs here: their model runs are Pro's + if (pro) row("Model runs", "come with Pro (suggest and ask)"); + else 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 = [a.includes.analytics && "visitor analytics", a.includes.forwarding && "alerts to Slack, Sentry and others", a.includes.export && "the stored events as a file", a.includes.privateRepositories && "private repositories"].filter(Boolean); const lacks = [!a.includes.analytics && "visitor analytics", !a.includes.forwarding && "alerts to Slack, Sentry and others", !a.includes.export && "the stored events as a file", !a.includes.privateRepositories && "private repositories"].filter(Boolean); if (has.length) row("Includes", has.join(", ")); @@ -199,7 +213,7 @@ export async function plan(o = {}, deps = {}) { return 0; } const dot = c.gray("·"); - const k = await typed(` ${PROMPT()} ${c.bold("Enter")} ${c.gray(`to start ${clean(offer.plan.name, 20)}`)} ${dot} ${c.bold("q")} ${c.gray("not now")} `); + const k = await typed(` ${PROMPT()} ${c.bold("Enter")} ${c.gray(`to start ${clean(offer.plan.name, 20)}`)} ${dot} ${c.bold("q")} ${c.gray("stop")} `); if (k === null || k !== "") { out(); return 0; @@ -256,30 +270,50 @@ export async function upgrade(o = {}, deps = {}) { const s = await signedIn(o, deps, "upgrade"); if ("code" in s) return s.code; const { state } = s; - // an account without Pro starts it here, for the whole account: the paywall the other commands show (paywall.mjs) + // an account without Pro starts it here, for the whole account: the paywall the other commands show (paywall.mjs). + // Until it is started this ends as they do: the needs_pro answer, exit code 1 const cli = state.account?.cli ?? null; + const paywall = await import("./paywall.mjs"); if (cli && !cli.paid) { if (o.json) { - out(JSON.stringify({ ok: true, app: null, plan: { name: cli.plan }, price: cli.price, trial_days: cli.trialDays, url: cli.checkoutUrl })); - return 0; + out(JSON.stringify(paywall.needsProJson(cli))); + return 1; } top(state); - await (await import("./paywall.mjs")).startPro(cli, state.account?.offer ?? null, { ...o, card: true }, deps); + const started = await paywall.startPro(cli, state.account?.offer ?? null, { ...o, card: true }, deps); out(); - return 0; + return started ? 0 : 1; } - const offer = upgradeOffer(state, o.app ?? null); const billingUrl = state.account?.billingUrl ?? hostUrl("/account/billing"); + const here = (state.projects ?? []).find((p) => p.token === state.app?.token && p.plan && !p.shared) ?? null; + // Pro already: an app past the ones it covers is Enterprise's (by email); nothing else is for sale here + const paying = accountPaid(state.account); + const full = paying && here?.needsPlan ? here : null; + const offer = paying ? null : upgradeOffer(state, o.app ?? null); + const viaTeam = cli?.via === "team" || state.account?.plan?.via === "team"; if (o.json) { - out(JSON.stringify({ ok: true, app: offer?.project?.name ?? null, plan: offer?.plan ?? null, price: offer?.price ?? null, url: offer?.checkoutUrl ?? billingUrl })); + if (paying) out(JSON.stringify({ ok: true, paid: true, via: viaTeam ? "team" : (cli?.via ?? null), plan: state.account?.plan ?? null, app: here?.name ?? null, needs_plan: !!full, ...(full ? { error: "the plan covers no more apps: more come with Enterprise, by email", plan_url: full.planUrl } : {}), billing_url: billingUrl })); + else out(JSON.stringify({ ok: true, app: offer?.project?.name ?? null, plan: offer?.plan ?? null, price: offer?.price ?? null, url: offer?.checkoutUrl ?? billingUrl })); return 0; } top(state); + if (full) { + warn(`Your plan covers no more apps, and ${clean(full.name, 30)} is one more. More apps come with Enterprise, by email.`); + if (full.planUrl) { + out(` ${c.gray("The address, on its plan page:")}`); + out(` ${c.cyan(full.planUrl)}`); + } + out(); + return 0; + } if (!offer) { - const here = (state.projects ?? []).find((p) => p.token === state.app?.token && p.plan); - if (here && here.plan.id !== "free") good(`${clean(here.name, 30)} is on ${clean(here.plan.name, 20)}.`); - else note("Nothing to upgrade here: every app of yours has a plan, or genclass.dev sells none here."); - out(` ${c.gray("Billing, in the browser")} ${c.cyan(billingUrl)}`); + 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."); + } 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")}`); + out(` ${c.cyan(billingUrl)}`); out(); return 0; } @@ -339,7 +373,7 @@ export async function profile(o = {}, deps = {}) { } choices.forEach(([label], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${label}`)); out(); - const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("back")} `); + const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("stop")} `); const pick = a !== null && /^\d+$/.test(a.trim()) ? choices[Number(a.trim()) - 1] : null; if (pick) { const r = await pick[2](); @@ -359,6 +393,15 @@ export async function team(o = {}, deps = {}) { const billing = state.account?.billingUrl ?? hostUrl("/account/billing"); if ((o.args ?? [])[0] === "invite") { 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)) { + if (o.json) out(JSON.stringify({ ok: false, error: "every seat is taken: add seats in Manage billing", billing_url: billing })); + else { + top(state); + seatsFull(billing); + } + return 1; + } if (!r.ok) { if (!o.json) top(state); return refused(r, { what: "A team", command: "team invite", browserUrl: billing, json: o.json }); @@ -390,10 +433,11 @@ export async function team(o = {}, deps = {}) { for (const i of t.invites) out(` ${c.gray(`an invite link is open${i.expires ? ` until ${day(i.expires)}` : ""}`)}`); for (const m of t.memberships) out(` ${c.gray("You are also in the team of")} ${clean(m.label, 50)}${m.active ? "" : c.gray(" (not active)")}`); out(); - if (t.seats !== null && t.seats <= 1) { + if (t.seats !== null && t.seats <= t.used && (a.access === "paid" || a.plan.id !== "free")) seatsFull(billing, t.seats); + else if (t.seats !== null && t.seats <= 1) { out(` ${c.bold(`${clean(a.plan.name, 20)} is for one person.`)}`); note("A team comes with Pro: everyone you invite opens the same dashboards."); - out(` ${c.cyan("npx genclass plan")}`); + out(` ${c.cyan("npx genclass upgrade")}`); } else { out(` ${c.gray("Invite someone:")} ${c.cyan("npx genclass team invite")}`); out(` ${c.gray(t.seats === null ? "Remove someone or change your plan, in the browser:" : "Remove someone or add seats, in the browser:")}`); @@ -403,6 +447,13 @@ export async function team(o = {}, deps = {}) { return 0; } +/** A paid plan whose seats are all taken (one seat: the buyer's): more people come with more seats, in Manage billing. */ +function seatsFull(billing, seats = null) { + out(` ${c.bold(seats === 1 ? "Your plan has one seat, yours." : "Every seat of your plan is taken.")}`); + note("Add seats in Manage billing, then invite your team: npx genclass team invite"); + out(` ${c.cyan(billing)}`); +} + // ----------------------------------------------------------------------------------------------- connections /** The destinations: [kind, name, [field, what is asked, hidden]]. */ diff --git a/packages/cli/src/paywall.mjs b/packages/cli/src/paywall.mjs index 3b63e0fb..20aed423 100644 --- a/packages/cli/src/paywall.mjs +++ b/packages/cli/src/paywall.mjs @@ -14,9 +14,11 @@ // on with the command that was asked for. // - Without a terminal (--yes, --json, a coding agent) nothing is opened and nothing waits: one line with the // checkout address, and exit code 1. -// - Asked once per process: once Pro is seen, the home's commands (home.mjs) go on without asking again. +// - Asked once per process: once Pro is seen, the home's commands (home.mjs) go on without asking again. The home's +// commands pass this gate like any other (the home can be opened before the sign-in, by q). import { readCredentials } from "../../runtime/bin/lib/account.mjs"; +import { accountLabel } from "../../runtime/bin/lib/init.mjs"; import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { fetchOverview } from "./api.mjs"; import { clean, note, planCard, PROMPT, typed, warn } from "./tui.mjs"; @@ -28,6 +30,40 @@ export const forgetPro = () => { seen = false; }; +/** Whether this process has seen the account's Pro (the gate passed, or Pro was started here). */ +export const proSeen = () => seen; + +/** + * Tells the runtime CLI's modules (init, connect) where a new app of this account goes, for the lines that say so: + * a member of a paying team with no plan of their own adds apps to the team's account (genclass.dev puts them + * there), not to their own. `who`: { plan, cli, teamPlan } from the overview (api.mjs fetchOverview, state.mjs). + */ +export function noteAccount(who) { + const host = globalThis.__GENCLASS_CLI__; + if (!host || typeof host !== "object" || !who) return; + const member = who.plan?.via === "team" || who.cli?.via === "team" || (!!who.teamPlan && (!who.plan || who.plan.id === "free")); + host.into = member ? "your team's account" : null; +} + +/** + * The one JSON answer for an account without Pro, whichever part of the command gives it (the gate, a route's 402, + * `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) { + return { + ok: false, + error: "the genclass command comes with Pro", + needs_pro: true, + signed_in: signedIn, + plan: cli?.plan ?? "Pro", + price: cli?.price ?? null, + trial_days: cli?.trialDays ?? null, + checkout_url: cli?.checkoutUrl ?? null, + run: signedIn ? "npx genclass upgrade" : "npx genclass login", + }; +} + /** * What the paywall shows for an account without Pro: the server's plan card (its offer, in genclass.dev's words) * with the trial and checkout of `cli`. @@ -39,7 +75,8 @@ export function proOffer(cli, offer) { plan: { name: cli.plan }, price: cli.price ?? offer?.price ?? "", summary: offer?.summary ?? null, - features: offer?.features ?? [], + // an account whose trial is used has none left: the card does not promise one + features: (offer?.features ?? []).filter((f) => cli.trialDays || !/days? free/i.test(f)), trialDays: cli.trialDays, checkoutUrl: cli.checkoutUrl, }; @@ -52,19 +89,21 @@ export function proLine(cli) { } /** - * The paywall, on a terminal: the card, then one question. Enter opens the checkout (genclass.dev sends the person - * straight to Stripe, the trial first) and waits for the payment; q stops. Without a terminal it prints proLine and - * opens nothing. Returns whether the account has Pro now. + * The paywall, on a terminal: the card, then one question. Enter (or y) opens the checkout (genclass.dev sends the + * person straight to Stripe, the trial first) and waits for the payment; anything else (q, n, s, exit) stops. + * Without a terminal it prints proLine and opens nothing. Returns whether the account has Pro now. * @param {{ plan: string, price: string | null, trialDays: number | null, checkoutUrl: string }} cli * @param {object | null} offer - * @param {{ yes?: boolean, json?: boolean, noBrowser?: boolean, card?: boolean }} o `card`: the card without a terminal too + * @param {{ yes?: boolean, json?: boolean, noBrowser?: boolean, card?: boolean, account?: string | null }} o `card`: + * the card without a terminal too; `account`: who it is for, said above the card (the checkout is the browser's + * account, so a person signed in there as someone else can tell) * @param {{ fetchFn?: typeof fetch, waitMs?: number, everyMs?: number }} [deps] */ export async function startPro(cli, offer, o = {}, deps = {}) { const card = proOffer(cli, offer); const heading = cli.trialDays ? `Start your ${cli.trialDays}-day free trial` : `genclass comes with ${clean(cli.plan, 20)}`; if (o.yes || o.json || !interactive()) { - if (o.json) out(JSON.stringify({ ok: false, needs_pro: true, plan: cli.plan, price: cli.price, trial_days: cli.trialDays, checkout_url: cli.checkoutUrl })); + if (o.json) out(JSON.stringify(needsProJson(cli))); else { // `genclass plan` and `upgrade` show the card all the same: it is what they are for if (o.card && card.features.length) planCard(card, null, heading); @@ -73,6 +112,10 @@ export async function startPro(cli, offer, o = {}, deps = {}) { } return false; } + if (o.account) { + out(); + note(`For ${clean(o.account, 60)}`); + } planCard(card, null, heading); note( cli.trialDays @@ -82,14 +125,17 @@ export async function startPro(cli, offer, o = {}, deps = {}) { out(); const dot = c.gray("·"); const a = await typed(` ${PROMPT()} ${c.bold("Enter")} ${c.gray(cli.trialDays ? "to start the free trial" : `to start ${clean(cli.plan, 20)}`)} ${dot} ${c.bold("q")} ${c.gray("stop")} `); - if (a === null || /^q/i.test(a)) { + // only Enter or a yes starts it: n, no, s, exit and q all stop, as q does everywhere else + if (a === null || !/^(y|yes)?$/i.test(a.trim())) { out(); note(proLine(cli)); return false; } const { checkout } = await import("./actions.mjs"); - if (await checkout({ ...card, pro: true }, o, deps)) return true; - note(`${clean(cli.plan, 20)} starts the moment the payment goes through; npx genclass goes on from there.`); + if (await checkout({ ...card, pro: true }, o, deps)) { + seen = true; + return true; + } return false; } @@ -104,7 +150,7 @@ export async function requirePro(o = {}, deps = {}) { let creds = readCredentials(); if (!creds) { if (o.yes || o.json || !interactive()) { - if (o.json) out(JSON.stringify({ ok: false, signed_in: false, needs_pro: true, run: "npx genclass login" })); + if (o.json) out(JSON.stringify(needsProJson(null, false))); else { out(); warn("Sign in first: npx genclass login. The genclass command comes with Pro; its free trial starts right after the sign-in."); @@ -119,12 +165,13 @@ export async function requirePro(o = {}, deps = {}) { if (!creds) return 1; } const me = await fetchOverview(creds, deps); + if (me.ok) noteAccount({ plan: me.plan, cli: me.cli, teamPlan: me.account?.teamPlan }); // a server that cannot be reached, or one that does not say, locks nothing here (its own routes still do) if (!me.ok || !me.cli || me.cli.paid) { if (me.ok && me.cli?.paid) seen = true; return 0; } - if (await startPro(me.cli, me.offer ?? null, o, deps)) { + if (await startPro(me.cli, me.offer ?? null, { ...o, account: accountLabel(me.account) }, deps)) { seen = true; out(); return 0; @@ -141,15 +188,15 @@ export async function afterLogin(o = {}, deps = {}) { const creds = readCredentials(); if (!creds) return false; const me = await fetchOverview(creds, deps); + if (me.ok) noteAccount({ plan: me.plan, cli: me.cli, teamPlan: me.account?.teamPlan }); if (!me.ok || !me.cli) return true; if (me.cli.paid) { seen = true; - if (!o.json) note(`${clean(me.plan && me.plan.id !== "free" ? me.plan.name : "Pro", 20)} is on: every genclass command works. Next: npx genclass`); + if (!o.json) note(`${clean(me.plan && me.plan.id !== "free" ? me.plan.name : "Pro", 20)} is on: every genclass command works.`); return true; } - if (await startPro(me.cli, me.offer ?? null, o, deps)) { + if (await startPro(me.cli, me.offer ?? null, { ...o, account: accountLabel(me.account) }, deps)) { seen = true; - note("Next: npx genclass"); return true; } return false; diff --git a/packages/cli/src/session.mjs b/packages/cli/src/session.mjs index f1210d64..81af3aa1 100644 --- a/packages/cli/src/session.mjs +++ b/packages/cli/src/session.mjs @@ -12,7 +12,7 @@ import { readCredentialsFor } from "../../runtime/bin/lib/account.mjs"; import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { signIn } from "./actions.mjs"; -import { proLine } from "./paywall.mjs"; +import { needsProJson, proLine } from "./paywall.mjs"; import { bad, clean, note, proBox, typed, warn } from "./tui.mjs"; /** @@ -26,7 +26,7 @@ export async function asking(o, creds, call, deps = {}) { if (!r.ok && r.reauth && !o.json && interactive()) { out(); note("This sign-in is from before the command could do this, so it was not approved for it. Signing in once more fixes it: the page lists what the terminal can do."); - const a = await typed(` ${c.bold("Enter")} ${c.gray("to sign in again,")} ${c.bold("q")} ${c.gray("to stop")} `); + const a = await typed(` ${c.bold("Enter")} ${c.gray("to sign in again ·")} ${c.bold("q")} ${c.gray("stop")} `); if (a === null || /^q/i.test(a)) return r; if ((await (deps.signIn ?? signIn)(o)) !== "done") return r; const again = readCredentialsFor().creds; @@ -44,7 +44,7 @@ export async function asking(o, creds, call, deps = {}) { export function refused(r, ctx) { // the genclass command is Pro's (paywall.mjs): the one way on, whatever was asked if (r.needsPro) { - if (ctx.json) out(JSON.stringify({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, checkout_url: r.needsPro.checkoutUrl, trial_days: r.needsPro.trialDays, run: "npx genclass upgrade" })); + if (ctx.json) out(JSON.stringify(needsProJson(r.needsPro))); else { out(); warn(proLine(r.needsPro)); diff --git a/packages/cli/src/setup.mjs b/packages/cli/src/setup.mjs index c0e94eb5..a30d54c5 100644 --- a/packages/cli/src/setup.mjs +++ b/packages/cli/src/setup.mjs @@ -25,7 +25,7 @@ import { addRuntime, addWorkflow, chooseAppPlan, choosePlan, connectAgent, conne import { brand, canDraw } from "./art.mjs"; import { BUILD } from "./assets.mjs"; import { appsBelow, planName, readState, stateJson } from "./state.mjs"; -import { barPara, card, checklist, header, nextBlock, note, pick, PROMPT, rule, summary, typed, warn } from "./tui.mjs"; +import { barPara, card, checklist, header, homeOpen, nextBlock, note, pick, PROMPT, rule, summary, typed, warn } from "./tui.mjs"; /** * Prints the header and the steps for a state: the short view, or with `details` every part and what is known @@ -33,8 +33,9 @@ import { barPara, card, checklist, header, nextBlock, note, pick, PROMPT, rule, */ export function show(state, details = false, welcome = false) { const h = { app: state.app.name, framework: state.app.framework, account: state.account?.label ?? null, plan: planName(state), host: state.host }; - // the first screen on a terminal is the welcome card; every later one, and anything piped, is the two-line header - if (welcome && canDraw()) card({ ...h, cwd: state.cwd, version: BUILD.version }); + // the first screen on a terminal is the welcome card; every later one, anything piped, and `status` typed in the + // home (which showed the card once, at the top) is the two-line header + if (welcome && canDraw() && !homeOpen()) card({ ...h, cwd: state.cwd, version: BUILD.version }); else header(h); if (details) checklist(state.steps); else summary(state.steps); @@ -103,9 +104,10 @@ function closing(state) { } out(); out(` ${c.gray("Day to day")}`); - out(` ${c.cyan("npx genclass".padEnd(23))}${c.gray("where things stand")}`); - out(` ${c.cyan("npx genclass suggest".padEnd(23))}${c.gray("what your app is missing")}`); - if (state.app.token) out(` ${c.cyan("npx genclass dashboard".padEnd(23))}${c.gray("what it found on your site")}`); + // the commands in one column with room after the longest, so the short name (genclass ...) keeps a gap too + out(` ${c.cyan("npx genclass".padEnd(25))}${c.gray("where things stand")}`); + out(` ${c.cyan("npx genclass suggest".padEnd(25))}${c.gray("what your app is missing")}`); + if (state.app.token) out(` ${c.cyan("npx genclass dashboard".padEnd(25))}${c.gray("what it found on your site")}`); out(); } @@ -180,7 +182,7 @@ async function walk(o, deps, auto) { // input that ended is a stop, never an Enter: nothing is started without someone at the keyboard const dot = c.gray("·"); rule(); - const keys = ` ${PROMPT()} ${c.bold("Enter")} ${c.gray("to continue")} ${dot} ${c.bold("d")} ${c.gray(details ? "less" : "every part")} ${dot} ${c.bold("s")} ${c.gray("skip")} ${dot} ${c.bold("q")} ${c.gray(o.home !== false && deps.home ? "later" : "stop")} `; + const keys = ` ${PROMPT()} ${c.bold("Enter")} ${c.gray("to continue")} ${dot} ${c.bold("d")} ${c.gray(details ? "less" : "every part")} ${dot} ${c.bold("s")} ${c.gray("skip")} ${dot} ${c.bold("q")} ${c.gray("stop")} `; const a = (await typed(keys)) ?? "q"; if (/^d/i.test(a)) { details = !details; @@ -190,7 +192,7 @@ async function walk(o, deps, auto) { // leaving the walk is not leaving the command: its other commands are one prompt away if (o.home !== false && deps.home) { out(); - note("The setup waits here: setup continues it."); + note("Setup stopped here. Type setup to go on with it."); return deps.home({ ...o, cwd: state.cwd, app: state.app.name ?? "" }); } out(); @@ -240,7 +242,7 @@ async function walk(o, deps, auto) { if (auto || !deps.runSuggest) { note(`Run ${c.cyan("npx genclass suggest")} to see what your app is missing.`); result = "skipped"; - } else result = (await deps.runSuggest({ cwd: o.cwd, fromSetup: true })) === 0 ? "done" : "failed"; + } else result = (await deps.runSuggest({ cwd: o.cwd, fromSetup: true, paid: state.steps.find((x) => x.id === "plan")?.state === "done" })) === 0 ? "done" : "failed"; } if (result === "failed") { failed = true; diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index 1d68a5bf..3e111b1a 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -69,6 +69,9 @@ export function hasPlaywright(cwd, project) { return ["ts", "js", "mjs", "cjs", "mts"].some((e) => existsSync(join(cwd, `playwright.config.${e}`))); } +/** 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."; + /** 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) { const parts = []; @@ -190,8 +193,9 @@ export async function readState(o = {}, deps = {}) { : otherHost ? `the saved session is for ${otherHost}` : "", - why: "Opens genclass.dev in your browser. New here? Your free account is made there.", - more: "Your account is where your apps, their dashboards and your plan live. Nothing is typed in the terminal.", + // 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.", + 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", }); @@ -207,15 +211,19 @@ export async function readState(o = {}, deps = {}) { doing: cli && !cli.paid ? (cli.trialDays ? `Start your ${cli.trialDays}-day free trial` : `Start ${cli.plan}`) : "Pro", state: !me ? (creds && offline !== "expired" ? "optional" : "todo") : !cli ? "optional" : cli.paid ? "done" : "todo", detail: !me - ? "" + ? creds + ? "" + : "3 days free, then US$15 a developer a month" : cli && !cli.paid ? `${cli.price ?? cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free` : ""}` - : cli?.paid && me.plan?.id === "free" + : cli?.paid && cli.via === "team" ? "through your team's plan" - : me.plan - ? me.plan.name - : "", - why: cli && !cli.paid ? `Every genclass command comes with ${cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free to start` : ""}.` : "Every genclass command comes with Pro.", + : cli?.paid && cli.via === "open" + ? "open on this genclass.dev" + : me.plan && me.plan.id !== "free" + ? me.plan.name + : "", + why: cli && !cli.paid ? `Every genclass command comes with ${cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free to start` : ""}.` : me ? "Every genclass command comes with Pro." : PRO_TERMS, more: `${cli && !cli.paid && cli.trialDays ? `The trial opens in your browser and the card is asked for there, on Stripe: nothing is charged if you cancel before the ${cli.trialDays} days end. ` : "Pro opens in your browser and is paid there, on Stripe. "}Free stays on the website: one app and its totals. Plans and prices: genclass.dev/pricing.`, run: "npx genclass upgrade", human: cli && !cli.paid ? `start ${cli.plan} in your browser: ${cli.checkoutUrl}` : null, @@ -378,15 +386,19 @@ export async function readState(o = {}, deps = {}) { human: s?.failed ? "work through the list: npx genclass suggest" : null, }); - // the first step to do that is not skipped and does not wait on a step that is not done (dashboard: the runtime) - const ready = (x) => !x.after || steps.find((y) => y.id === x.after)?.state === "done"; + // the first step to do that is not skipped and does not wait on a step that is not done (dashboard: the runtime). + // Every step after Pro's waits for it: the genclass command is Pro's, so skipping the sign-in skips them too (a + // server that sells nothing, or cannot be reached, leaves Pro's step optional and locks nothing) + const pro = steps.find((y) => y.id === "plan"); + const proReady = pro.state === "done" || pro.state === "optional"; + const ready = (x) => (x.id === "account" || x.id === "plan" || proReady) && (!x.after || steps.find((y) => y.id === x.after)?.state === "done"); const next = steps.find((x) => x.state === "todo" && !(o.skip ?? []).includes(x.id) && ready(x)) ?? null; if (next) next.state = "next"; return { cwd, host: host.overridden && !host.error ? host.host : null, app: { name: app?.name ?? project.name ?? null, framework: project.label || null, isApp, token: st.token ?? null, dashboardUrl: app?.dashboard_url ?? st.local?.dashboardUrl ?? null, site: notes.site ?? null, ...(isApp ? {} : { below: appsBelow(cwd) }) }, - account: me ? { label: accountLabel(me.account), plan: me.plan, suggestions: me.suggestions, billingUrl: me.billingUrl, github: me.github, offer: me.offer ?? null, cli: me.cli ?? null } : null, + account: me ? { label: accountLabel(me.account), plan: me.plan, suggestions: me.suggestions, billingUrl: me.billingUrl, github: me.github, offer: me.offer ?? null, cli: me.cli ?? null, teamPlan: me.account?.teamPlan ?? null } : null, signedIn: !!me, /** the account's apps as genclass.dev lists them ([] signed out) */ projects: me?.projects ?? [], @@ -418,7 +430,7 @@ export function appPlanOffer(app, accountOffer, paying) { const covered = !!app.plan.source && !app.needsPlan; if (!covered && !accountOffer && !paying) return null; const base = accountOffer ?? { plan: { name: "Pro" }, price: "", summary: null, features: [], free: { name: "Free", apps: null } }; - return { ...base, checkoutUrl: `${app.planUrl}&from=cli`, project: { id: app.id, name: app.name, token: app.token }, needs: app.needsPlan === true, current: covered ? app.plan.name : null, paying: !!paying }; + return { ...base, checkoutUrl: `${app.planUrl}&from=cli`, project: { id: app.id, name: app.name, token: app.token }, needs: app.needsPlan === true, current: covered ? app.plan.name : null, paying: !!paying, chosen: app.planChosen === true }; } /** The plan of the app with `token` (plans are per project), else the account's: what a header shows. */ diff --git a/packages/cli/src/suggest/index.mjs b/packages/cli/src/suggest/index.mjs index e6ac9295..c5bb65b5 100644 --- a/packages/cli/src/suggest/index.mjs +++ b/packages/cli/src/suggest/index.mjs @@ -16,6 +16,7 @@ import { readCredentials } from "../../../runtime/bin/lib/account.mjs"; import { inspectProject } from "../../../runtime/bin/lib/init.mjs"; import { c, interactive, out, sym } from "../../../runtime/bin/lib/ui.mjs"; import { requestPlan } from "../api.mjs"; +import { proSeen } from "../paywall.mjs"; import { copyText, findAgents, startAgent } from "../fix.mjs"; import { readNotes, saveNotes } from "../store.mjs"; import { agree, clean, good, header, note, para, proBox, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs"; @@ -42,9 +43,10 @@ export function siteAddress(v) { /** * The plan to print: the failed checks in order, each with the words to show and whether it is shown in full. * `plan` is the server's answer (api.mjs requestPlan) or null; without it the order is by weight and the first - * FREE_SHOWN are shown in full with the catalog's own words. + * FREE_SHOWN are shown in full with the catalog's own words, or every one for an account with Pro (`paid`: no plan + * was asked for, --no-model or nothing agreed to send, but the account's own plan shows them all). */ -export function layOut(results, plan) { +export function layOut(results, plan, paid = false) { const failed = results.filter((r) => r.status === "fail"); const by = new Map(failed.map((r) => [r.id, r])); const order = []; @@ -55,7 +57,7 @@ export function layOut(results, plan) { } // whatever the server did not name (or everything, without a server), heaviest first const rest = failed.filter((r) => !order.includes(r.id)).sort((a, b) => b.weight - a.weight); - const shown = plan ? plan.items.length : Math.min(FREE_SHOWN, rest.length); + const shown = plan ? plan.items.length : paid ? rest.length : Math.min(FREE_SHOWN, rest.length); for (const r of rest) order.push(r.id); return order.map((id, n) => { const r = by.get(id); @@ -189,7 +191,7 @@ async function handToAgent(items, url, cwd, o, deps, plan) { choices.forEach(([label], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${label}`)); if (!agents.length) note("No coding agent's command found here (claude, codex, cursor-agent, gemini)."); out(); - const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("not now")} `); + const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("stop")} `); const pick = a !== null && /^\d+$/.test(a.trim()) ? choices[Number(a.trim()) - 1] : null; if (pick) await pick[1](); } @@ -208,11 +210,13 @@ export async function suggest(o = {}, deps = {}) { return 2; } if (!quiet) header({ app: repo.name ?? st.project.name, framework: st.project.label || null, account: readCredentials()?.account?.email ?? null }); - if (!url && !quiet && !o.yes && interactive()) { + // asked once per project: Enter (the repository only) is remembered, and the address can still be given + if (!url && !notes.repoOnly && !quiet && !o.yes && interactive()) { para("Where is this app live? With its address GenClass also checks what search engines, AI assistants and visitors get from it.", " "); const a = await typed(` ${c.bold("Address")} ${c.gray("(or Enter for the repository only)")} `); url = siteAddress(a); if (a && !url) warn("That is not a site address, so only the repository is checked this time."); + if (a === "") saveNotes(cwd, { repoOnly: true }); } let site = null; if (url) { @@ -258,7 +262,8 @@ export async function suggest(o = {}, deps = {}) { } } } - const items = layOut(results, plan); + // no plan from genclass.dev (--no-model, nothing sent): the account's own plan says how much is shown + const items = layOut(results, plan, !plan && !!creds && (o.paid === true || proSeen())); const locked = items.filter((i) => !i.open); saveNotes(cwd, { ...(site?.reachable ? { site: url } : {}), suggest: { at: new Date().toISOString(), failed: failed.length, urgent: failed.filter((r) => r.weight >= 4).length, passed: passed.length, top: items.slice(0, 3).map((i) => i.id) } }); @@ -315,7 +320,7 @@ export async function suggest(o = {}, deps = {}) { if (unknown.length) { out(); // without the site's address these could not be measured: a count is enough, the line above says how to get them - if (!site?.reachable) note(`${unknown.length} more checks need your live site${url ? "" : `: ${c.cyan("npx genclass suggest https://your-site.com")}`}.`); + if (!site?.reachable) note(`${unknown.length} more checks need your live site${url ? "." : `: ${c.cyan("npx genclass suggest https://your-site.com")}`}`); else note(`${unknown.length} check${unknown.length === 1 ? "" : "s"} could not be measured on this site this time.`); } out(); diff --git a/packages/cli/src/tui.mjs b/packages/cli/src/tui.mjs index f1ebf3de..d7d9a3a4 100644 --- a/packages/cli/src/tui.mjs +++ b/packages/cli/src/tui.mjs @@ -42,12 +42,69 @@ export function clean(text, max = 400) { /** The visible length of a string that may hold colour codes. */ export const visible = (s) => String(s).replace(COLOUR, "").length; -/** `text` wrapped to `cols` columns: an array of lines (words longer than a line are left whole). */ +/** The words that follow `genclass` (or npm) in a command: its commands and their subcommands. */ +const SUBCOMMANDS = new Set( + "setup status suggest dashboard problems analytics events health report export fix ask scan audit connections agent funnels apps plan upgrade profile team login logout whoami connect init remove help version new add invite link test on off pages sources devices clicks paths stale all install uninstall".split(" "), +); +/** The options of a command that take the word after them. */ +const VALUED = /^--?(cwd|app|range|out|from|to|format|agent|name|mode|token|url|model-url|sink|variant|ort)$/; +const plainWord = (w) => w.replace(COLOUR, ""); +/** A word that ends a sentence or a clause (a URL's own last character does not). */ +const closes = (p) => /[.,;:!?)]$/.test(p) && !/^https?:\/\//.test(p); + +/** + * The words of one paragraph grouped so that a command is one unit: `npx genclass init --no-telemetry`, + * `npx genclass --cwd `, `npm install -g genclass`, `npx genclass apps new "My shop"`. A unit is never + * longer than `cols` because of the grouping: a command that would be is left to break as words do. + */ +function units(words, cols) { + const list = []; + let cmd = false; + let quote = false; + let valueNext = false; + for (const w of words) { + const p = plainWord(w); + const last = list.length ? plainWord(list[list.length - 1].at(-1)) : ""; + let join = false; + if (list.length && cmd) { + join = + quote || + valueNext || + last === "npx" || + (/^(genclass|@genclass\/)/.test(p) && /^(install|uninstall|-g)$/.test(last)) || + /^-{1,2}[A-Za-z][\w-]*/.test(p) || + /^<[^>]*>/.test(p) || + /^"/.test(p) || + /^https?:\/\//.test(p) || + /^\d+[.,;:)]?$/.test(p) || + SUBCOMMANDS.has(p.replace(/[.,;:)]$/, "")); + if (join && visible(list[list.length - 1].join(" ")) + 1 + visible(w) > cols) join = false; + } + if (join) list[list.length - 1].push(w); + else { + list.push([w]); + cmd = /^(npx|npm)$/.test(p) || /^(genclass|@genclass\/\S+?)[.,;:)]?$/.test(p); + quote = false; + valueNext = false; + if (!cmd) continue; + } + if (!quote && /^"/.test(p) && !/.["][.,;:)]?$/.test(p)) quote = true; + else if (quote && /"[.,;:)]?$/.test(p)) quote = false; + valueNext = !quote && VALUED.test(p); + if (!quote && closes(p)) cmd = false; + } + return list.map((u) => u.join(" ")); +} + +/** + * `text` wrapped to `cols` columns: an array of lines. A word longer than a line is left whole, and a command (npx + * genclass ...) is never broken across lines: it moves to the next line whole, so it can be copied as typed. + */ export function wrap(text, cols = width() - 4) { const lines = []; for (const para of String(text).split("\n")) { let line = ""; - for (const word of para.split(/\s+/).filter(Boolean)) { + for (const word of units(para.split(/[ \t\r\f\v]+/).filter(Boolean), cols)) { if (line && visible(line) + 1 + visible(word) > cols) { lines.push(line); line = word; @@ -71,6 +128,8 @@ let homeApp = null; export const inHome = (app) => { homeApp = app === null ? null : clean(app ?? "", 40); }; +/** Whether the command's home is open (its card was shown once, at the top). */ +export const homeOpen = () => homeApp !== null; /** * The header: the product, this app and its framework on one line, the account and its plan under it. @@ -109,10 +168,13 @@ export function folderName(dir, max = 48, home = homedir()) { return short.length > max ? `…${short.slice(short.length - max + 1)}` : short; } -/** What to call the person on the card: the start of their email address or their GitHub name, else nothing. */ +/** + * What to call the person on the card: their GitHub name, else nothing. The start of an email address is not a name + * ("walk58@..." would be greeted as "Walk58"), so an account known by its email is welcomed without one. + */ export function firstName(label) { const l = clean(label ?? "", 80); - const raw = /^GitHub (\S+)$/.exec(l)?.[1] ?? (l.includes("@") ? l.split("@")[0].split(/[._+-]/)[0] : ""); + const raw = /^GitHub (\S+)$/.exec(l)?.[1] ?? ""; const name = raw.replace(/[^\p{L}\p{N}]/gu, "").slice(0, 24); return name ? name[0].toUpperCase() + name.slice(1) : null; } @@ -322,24 +384,44 @@ export async function secret(question) { /** A line of small print. */ export const note = (text) => para(text, " ", c.gray); +/** A line behind a mark, wrapped to the window: the lines after the first stand under its text, not under the mark. */ +const marked = (mark, text) => { + const [first = "", ...rest] = wrap(text, width() - 6); + out(` ${mark} ${first}`); + for (const l of rest) out(` ${l}`); +}; /** A finished thing. */ -export const good = (text) => out(` ${c.green(sym.ok)} ${text}`); +export const good = (text) => marked(c.green(sym.ok), text); /** Something that needs attention and is not an error. */ -export const warn = (text) => out(` ${c.yellow(sym.warn)} ${text}`); +export const warn = (text) => marked(c.yellow(sym.warn), text); /** Something that failed. */ -export const bad = (text) => out(` ${c.red(sym.cross)} ${text}`); +export const bad = (text) => marked(c.red(sym.cross), text); const FRAMES = unicode ? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] : ["-", "\\", "|", "/"]; +/** What a stoppable wait says: after its line when it fits, else on its own line above it. */ +export const STOP_HINT = "(Enter to stop waiting)"; + +/** `label` cut to what fits on one line of the terminal after the spinner's mark (a wrapped line could not be redrawn). */ +export function fitLine(label, columns = Number(process.stdout.columns) || 80) { + const room = Math.max(12, columns - 5); + return visible(label) <= room ? label : `${String(label).replace(COLOUR, "").slice(0, room - 1)}…`; +} + /** - * A spinner for a wait. On a terminal it redraws one line; piped, it prints the text once. `stop(mark, text)` ends - * it with one final line (`mark`: "ok", "warn", "bad" or null for no line). + * A spinner for a wait. On a terminal it redraws one line, never wider than the window (a line that wrapped would + * leave a copy of itself on every frame); piped, it prints the text once. `stoppable`: the wait ends on Enter, and + * the line says so. `stop(mark, text)` ends it with one final line (`mark`: "ok", "warn", "bad" or null for no line). */ -export function spinner(text) { +export function spinner(text, opts = {}) { let label = text; let i = 0; let timer = null; - const draw = () => process.stdout.write(`\r\u001b[2K ${c.cyan(FRAMES[i++ % FRAMES.length])} ${label}`); + if (opts.stoppable && tty()) { + if (visible(`${text} ${STOP_HINT}`) <= Math.max(12, (Number(process.stdout.columns) || 80) - 5)) label = `${text}${c.gray(` ${STOP_HINT}`)}`; + else out(` ${c.gray("Enter stops the wait.")}`); + } + const draw = () => process.stdout.write(`\r\u001b[2K ${c.cyan(FRAMES[i++ % FRAMES.length])} ${fitLine(label)}`); if (tty()) { draw(); timer = setInterval(draw, 90); diff --git a/packages/cli/src/views.mjs b/packages/cli/src/views.mjs index 8407c022..8d5da009 100644 --- a/packages/cli/src/views.mjs +++ b/packages/cli/src/views.mjs @@ -115,7 +115,10 @@ export function renderAnalytics(app, o = {}) { if (app.visitorsPlan) { out(); out(` ${c.bold(`Visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Pro", 20)} plan.`)}`); - note("What your visitors do is already being counted, so it shows from the day you switch: visits, pages, time on a page, where people come from, what they click, funnels."); + // "already counted" only when the app sends visitor events (page views in the totals); else it is the app's switch + const counted = (t?.ix_views ?? 0) > 0 || (app.totals?.pageViews ?? 0) > 0; + note(counted ? "What your visitors do is already being counted, so it shows from the day you switch: visits, pages, time on a page, where people come from, what they click, funnels." : "Visits, pages, time on a page, where people come from, what they click, funnels. Your app turns it on with one line, no cookies:"); + if (!counted) out(` ${c.cyan("https://genclass.dev/docs/analytics")}`); out(` ${c.cyan("npx genclass plan")}`); out(); return; diff --git a/packages/cli/test/actions.test.ts b/packages/cli/test/actions.test.ts index 089397fe..e0bf2d01 100644 --- a/packages/cli/test/actions.test.ts +++ b/packages/cli/test/actions.test.ts @@ -127,7 +127,8 @@ describe("genclass: nothing is drawn on a page (src/actions.mjs runtimeArgs)", ( expect(said).toMatch(/Adds @genclass\/runtime \S+ in observe mode: it reports and never changes anything\./); expect(said).toContain("Anonymous diagnostics are on"); expect(said).toContain("init --no-telemetry"); - expect(said).toContain("What is sent: https://"); + // the address on a line of its own, so it is never broken by the line before it + expect(said).toMatch(/What is sent:\s*\n\s*https:\/\/\S+TELEMETRY\.md/); expect(said).not.toMatch(/✔ Version|✔ Packages|✔ Entry|GenClass • init/); const entry = readFileSync(join(dir, "src/main.jsx"), "utf8"); expect(entry).toContain('import "@genclass/runtime/auto"'); diff --git a/packages/cli/test/art.test.ts b/packages/cli/test/art.test.ts index 4b776f4c..a879f476 100644 --- a/packages/cli/test/art.test.ts +++ b/packages/cli/test/art.test.ts @@ -63,11 +63,13 @@ describe("genclass: the welcome card (src/art.mjs header, src/tui.mjs card)", () } }); - it("someone signed in is welcomed back by name; a new person is told what GenClass does", async () => { + it("someone signed in is welcomed back (by their GitHub name, never by the start of an email); a new person is told what GenClass does", async () => { const back = plain(await printed(() => card({ app: "shop", framework: "Vite + React", account: "ada.lovelace@example.com", plan: "Team", cwd: "/home/ada/shop", version: "9.9.9" }))); expect(back).toContain("╭─ GenClass ─"); expect(back).toContain(" v9.9.9 ─╮"); - expect(back).toContain("Welcome back, Ada!"); + expect(back).toContain("Welcome back!"); + expect(back).not.toContain("Welcome back, Ada"); + expect(plain(await printed(() => card({ account: "GitHub ada-l", version: "1" })))).toContain("Welcome back, Adal!"); expect(back).toContain("ada.lovelace@example.com • Team plan"); expect(back).toContain("shop • Vite + React"); expect(back).not.toContain("Finds what breaks"); @@ -82,9 +84,9 @@ describe("genclass: the welcome card (src/art.mjs header, src/tui.mjs card)", () expect(plain(await printed(() => card({ account: null, host: "http://localhost:8797", version: "1" })))).toContain("talking to http://localhost:8797"); }); - it("names: the start of an email address or a GitHub name, capitalised; nothing when there is no name in it", () => { - expect(firstName("mehar@daybot.ca")).toBe("Mehar"); - expect(firstName("ada.lovelace+work@example.com")).toBe("Ada"); + it("names: a GitHub name, capitalised; nothing for an email address (walk58@... is not a name) or no name at all", () => { + expect(firstName("mehar@daybot.ca")).toBeNull(); + expect(firstName("walk58@example.com")).toBeNull(); expect(firstName("GitHub octo-cat")).toBe("Octocat"); expect(firstName("account acct0000000000000000001")).toBeNull(); expect(firstName("")).toBeNull(); diff --git a/packages/cli/test/gate.test.ts b/packages/cli/test/gate.test.ts new file mode 100644 index 00000000..7dd5abe2 --- /dev/null +++ b/packages/cli/test/gate.test.ts @@ -0,0 +1,290 @@ +// The Pro gate's edges (src/cli.mjs, src/paywall.mjs) and the text helpers at 58 columns (src/tui.mjs): what works +// without Pro (the telemetry opt-out, an app's private dashboard link, a command's own usage errors), what login does +// before and after Pro, the paywall's question at a terminal, and lines that never break a command or overflow. +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { saveCredentials } from "../../runtime/bin/lib/account.mjs"; +import { run as runInit } from "../../runtime/bin/lib/init.mjs"; +import { connectAgent, projectPlan, sameApp, upgradeOffer } from "../src/actions.mjs"; +import { run, telemetryOnly } from "../src/cli.mjs"; +import { fetchOverview } from "../src/api.mjs"; +import { forgetPro, noteAccount, proOffer, startPro } from "../src/paywall.mjs"; +import { layOut } from "../src/suggest/index.mjs"; +import { fitLine, spinner, warn, wrap } from "../src/tui.mjs"; +import { ACCOUNT, fakeServer, HOST, isolate, overview, printed, project, SESSION, tempDir, TOKEN, viteApp, write } from "./helpers.js"; +import { plainLines } from "./stats.js"; + +isolate(); + +const flat = (t: string) => plainLines(t).join(" ").replace(/\s+/g, " "); +const signIn = () => saveCredentials({ host: HOST, access_token: SESSION, account: ACCOUNT }); +const CHECKOUT = `${HOST}/account/billing?plan=pro&start=1`; +const LOCKED = { paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT }; +/** stderr, quiet for a call (usage errors go there). */ +async function quietErr(fn: () => Promise): Promise<{ value: T; err: string }> { + let err = ""; + const spy = vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => ((err += String(chunk)), true)) as never); + try { + return { value: await fn(), err }; + } finally { + spy.mockRestore(); + } +} +/** The runtime's own init, set up with a token and nothing installed (an app with GenClass in it). */ +const setUp = async (dir: string) => { + await printed(async () => expect(await runInit("init", ["--cwd", dir, "--yes", "--no-install", "--no-network", "--token", TOKEN])).toBe(0)); +}; + +describe("genclass without Pro: every opt-out and the app's own link keep working (AGENTS.md rule 8; src/cli.mjs)", () => { + it("init --no-telemetry (and --telemetry) on an app with GenClass in it runs signed out, with no gate", async () => { + const dir = tempDir(); + viteApp(dir); + await setUp(dir); + forgetPro(); + const off = await printed(async () => expect(await run(["init", "--no-telemetry", "--yes", "--no-install", "--cwd", dir])).toBe(0)); + expect(off).not.toContain("Sign in first"); + expect(readFileSync(join(dir, "src/genclass.config.js"), "utf8")).toContain("telemetry: false"); + const on = await printed(async () => expect(await run(["init", "--telemetry", "--yes", "--no-install", "--cwd", dir])).toBe(0)); + expect(on).not.toContain("Sign in first"); + expect(readFileSync(join(dir, "src/genclass.config.js"), "utf8")).not.toContain("telemetry: false"); + // what is only the opt-out: the switch with where, whether to ask and what not to run + expect(telemetryOnly(["--no-telemetry"])).toBe(true); + expect(telemetryOnly(["--telemetry", "--cwd", "web", "--yes", "--dry-run", "--no-install", "--no-network"])).toBe(true); + expect(telemetryOnly(["--no-telemetry", "--mode", "guard"])).toBe(false); + expect(telemetryOnly(["--yes"])).toBe(false); + // on an app without GenClass, init --no-telemetry would add it: that is the command's, behind the gate + const fresh = tempDir(); + viteApp(fresh); + const gated = await printed(async () => expect(await run(["init", "--no-telemetry", "--yes", "--cwd", fresh])).toBe(1)); + expect(flat(gated)).toContain("Sign in first: npx genclass login"); + expect(existsSync(join(fresh, "src/genclass.config.js"))).toBe(false); + }); + + it("a command's own usage error comes before the gate: nothing is asked of genclass.dev for words that are wrong", async () => { + const dir = tempDir(); + viteApp(dir); + forgetPro(); + for (const argv of [["init", "--bogus"], ["connect", "--mode", "guard"], ["scan"], ["audit", "not a url"], ["scan", "https://a.example", "--bogus"]]) { + const r = await quietErr(() => printed(async () => expect(await run([...argv, "--cwd", dir]), argv.join(" ")).toBe(2))); + expect(r.value, argv.join(" ")).not.toContain("Sign in first"); + expect(r.err, argv.join(" ")).toMatch(/unknown option|needs a URL|not a URL|unexpected argument|needs a value/); + } + }); + + it("signed out, genclass dashboard in a folder with an app's private link prints it, as 0.3.6 did", async () => { + const link = `${HOST}/dashboard/${"a".repeat(32)}`; + const dir = tempDir(); + viteApp(dir); + write(dir, { ".genclass.local": `GENCLASS_TOKEN=${TOKEN}\nGENCLASS_DASHBOARD=${link}\n` }); + forgetPro(); + const said = await printed(async () => expect(await run(["dashboard", "--cwd", dir])).toBe(0)); + expect(said).toContain(link); + expect(said).not.toContain("Sign in first"); + expect(flat(said)).not.toContain("free for every app"); + }); + + it("an account without Pro is told so before ask's privacy notice, and nothing is agreed to", async () => { + const dir = tempDir(); + viteApp(dir); + await setUp(dir); + signIn(); + const fetchFn = fakeServer({ "GET /api/cli/overview": { json: overview({ cli: LOCKED, projects: [project()] }) } }).fetchFn; + const { ask } = await import("../src/ask.mjs"); + let saved = false; + const r = await quietErr(() => printed(async () => expect(await ask({ question: "which page is slow?", cwd: dir }, { fetchFn, savePrefs: () => (saved = true) })).toBe(1))); + expect(flat(r.value)).toContain(`start your 3-day free trial at ${CHECKOUT}`); + expect(r.err).not.toContain("Your question goes to"); + expect(saved).toBe(false); + }); +}); + +describe("genclass login: the sign-in only, Pro next, and the app connected only once Pro is on (src/cli.mjs, runtime login.mjs offerConnect)", () => { + it("without Pro nothing is written in the folder; with Pro the folder's app is offered to the account", async () => { + const saved = process.env.GENCLASS_TEST_DIST_TAGS; + process.env.GENCLASS_TEST_DIST_TAGS = "fail"; + try { + const dir = tempDir(); + viteApp(dir); + const before = readFileSync(join(dir, "src/main.jsx"), "utf8"); + signIn(); + const me = { json: { ok: true, account: ACCOUNT, projects: [] } }; + const locked = fakeServer({ "GET /api/me": me, "GET /api/cli/overview": { json: overview({ cli: LOCKED }) } }); + vi.stubGlobal("fetch", locked.fetchFn); + forgetPro(); + const no = await printed(async () => expect(await run(["login", "--yes", "--no-install", "--cwd", dir])).toBe(0)); + expect(flat(no)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${CHECKOUT}`); + expect(no).not.toMatch(/Connect shop|Changes|GenClass is in your app/); + expect(readFileSync(join(dir, "src/main.jsx"), "utf8")).toBe(before); + expect(locked.calls.some((x) => x.method === "POST")).toBe(false); + // Pro: the runtime login's own question comes after it (here, with no terminal, it says how to connect) + const paid = fakeServer({ "GET /api/me": me, "GET /api/cli/overview": { json: overview({ plan: { id: "team", name: "Pro" }, cli: { paid: true, via: "plan" } }) } }); + vi.stubGlobal("fetch", paid.fetchFn); + forgetPro(); + const yes = flat(await printed(async () => expect(await run(["login", "--cwd", dir])).toBe(0))); + expect(yes).toContain("Pro is on: every genclass command works."); + expect(yes).toMatch(/Not a terminal: run \S+( \S+)? connect --yes here to connect this app\./); + expect(yes.indexOf("Pro is on")).toBeLessThan(yes.indexOf("Not a terminal")); + } finally { + vi.unstubAllGlobals(); + if (saved === undefined) delete process.env.GENCLASS_TEST_DIST_TAGS; + else process.env.GENCLASS_TEST_DIST_TAGS = saved; + } + }); +}); + +describe("genclass: a team member's new app goes into the team's account (src/paywall.mjs noteAccount, runtime init.mjs accountWhere)", () => { + it("the overview's team_plan and via say so, and the runtime's lines are told; the runtime alone says your account", async () => { + signIn(); + const fetchFn = fakeServer({ "GET /api/cli/overview": { json: overview({ account: { ...ACCOUNT, team_plan: { id: "pro", name: "Pro" } }, cli: { paid: true, via: "team" } }) } }).fetchFn; + const me = await fetchOverview({ host: HOST, access_token: SESSION, account: ACCOUNT }, { fetchFn }); + expect(me.ok && me.account.teamPlan).toEqual({ id: "pro", name: "Pro" }); + const saved = (globalThis as { __GENCLASS_CLI__?: unknown }).__GENCLASS_CLI__; + const host: { into?: string | null } = {}; + (globalThis as { __GENCLASS_CLI__?: unknown }).__GENCLASS_CLI__ = host; + try { + noteAccount({ plan: { id: "free", name: "Free" }, cli: { paid: true, via: "team" }, teamPlan: { id: "pro", name: "Pro" } }); + expect(host.into).toBe("your team's account"); + noteAccount({ plan: { id: "free", name: "Free" }, cli: null, teamPlan: { id: "pro", name: "Pro" } }); + expect(host.into).toBe("your team's account"); + // their own plan: their own account + noteAccount({ plan: { id: "pro", name: "Pro" }, cli: { paid: true, via: "plan" }, teamPlan: { id: "pro", name: "Pro" } }); + expect(host.into).toBeNull(); + } finally { + (globalThis as { __GENCLASS_CLI__?: unknown }).__GENCLASS_CLI__ = saved; + } + const { accountWhere } = await import("../../runtime/bin/lib/init.mjs"); + expect(accountWhere(ACCOUNT)).toBe("your account (ada@example.com)"); + }); +}); + +describe("genclass: the plan's card and its answers (src/paywall.mjs, src/suggest/index.mjs layOut)", () => { + it("a used trial is not promised in the card; a Pro account with no plan from the server sees every suggestion", () => { + const features = ["3 days free to start", "Up to 3 apps", "Visitor analytics"]; + expect(proOffer({ plan: "Pro", price: "$15", trialDays: null, checkoutUrl: CHECKOUT }, { features } as never).features).toEqual(["Up to 3 apps", "Visitor analytics"]); + expect(proOffer({ plan: "Pro", price: "$15", trialDays: 3, checkoutUrl: CHECKOUT }, { features } as never).features).toEqual(features); + const results = [1, 2, 3].map((i) => ({ id: `c${i}`, status: "fail", weight: i, title: `t${i}`, why: "w", fix: "f", prompt: "p" })); + expect(layOut(results as never, null).filter((i) => i.open)).toHaveLength(1); + expect(layOut(results as never, null, true).filter((i) => i.open)).toHaveLength(3); + }); +}); + +describe("genclass at 58 columns: commands stay whole, a spinner never wraps (src/tui.mjs)", () => { + it("wrap never breaks a command, and warnings are wrapped like every other line", async () => { + const text = "Sign in first: npx genclass login. The genclass command comes with Pro. Off: npx genclass init --no-telemetry and more: npx genclass --cwd or npx genclass apps new \"My shop\" or npm install -g genclass by hand."; + for (const cols of [32, 40, 54]) { + const lines = wrap(text, cols); + const joined = lines.join("\n"); + for (const cmd of ["npx genclass login.", "npx genclass init --no-telemetry", "npx genclass --cwd ", 'npx genclass apps new "My shop"', "npm install -g genclass"]) expect(joined, `${cols}: ${cmd}`).toContain(cmd); + // nothing but a command longer than the line is longer than the line + for (const l of lines) if (!/genclass/.test(l)) expect(l.length, l).toBeLessThanOrEqual(cols); + } + // a sentence is still a sentence: "the genclass command" is prose, not a command to keep whole + expect(wrap("the genclass command comes with Pro", 12)).toEqual(["the genclass", "command", "comes with", "Pro"]); + const cols = process.stdout.columns; + process.stdout.columns = 58; + try { + const said = await printed(() => warn("Could not add the genclass command. npx genclass keeps working, and this sentence goes on again for a while.")); + for (const l of plainLines(said)) expect(l.length, l).toBeLessThanOrEqual(58); + expect(flat(said)).toContain("npx genclass keeps working"); + } finally { + process.stdout.columns = cols; + } + }); + + it("a spinner's line fits the window: the stop hint goes on its own line when it does not fit after it", async () => { + expect(fitLine("Waiting for you to approve in the browser (Enter to stop waiting)", 58).length).toBeLessThanOrEqual(53); + expect(fitLine("short", 58)).toBe("short"); + const cols = process.stdout.columns; + const isTTY = process.stdout.isTTY; + process.stdout.columns = 58; + (process.stdout as { isTTY: boolean }).isTTY = true; + try { + const said = await printed(async () => { + const sp = spinner("Waiting for you to approve in the browser", { stoppable: true }); + await new Promise((r) => setTimeout(r, 200)); + sp.stop(null); + }); + // every frame, without its redraw codes, fits on one line of 58 columns + for (const frame of said.split("\r").map((f) => f.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").replace(/\n/g, ""))) expect(frame.length, frame).toBeLessThanOrEqual(57); + expect(said).toContain("Enter stops the wait."); + } finally { + process.stdout.columns = cols; + (process.stdout as { isTTY: boolean | undefined }).isTTY = isTTY; + } + }); +}); + +describe("genclass at a terminal: the paywall's question, the plan page's wait, the agent's files (src/paywall.mjs, src/actions.mjs)", () => { + const input = new PassThrough(); + (input as unknown as { isTTY: boolean }).isTTY = true; + const real = Object.getOwnPropertyDescriptor(process, "stdin")!; + beforeAll(() => Object.defineProperty(process, "stdin", { value: input, configurable: true })); + afterAll(() => Object.defineProperty(process, "stdin", real)); + const type = (...lines: string[]) => lines.forEach((l, i) => setTimeout(() => input.write(`${l}\n`), 30 * (i + 1))); + const cli = { paid: false, plan: "Pro", price: "$15 / developer / month", trialDays: 3, checkoutUrl: CHECKOUT }; + + it("n, no, s and exit at Pro's card stop as q does; the card says which account it is for", async () => { + for (const answer of ["n", "no", "s", "exit", "q"]) { + type(answer); + let got: boolean | null = null; + const said = await printed(async () => (got = await startPro(cli, null, { account: "ada@example.com" }, { waitMs: 50, everyMs: 10 }))); + expect(got, answer).toBe(false); + expect(said, answer).not.toMatch(/Waiting for the payment|Your browser is opening/); + expect(flat(said), answer).toContain("For ada@example.com"); + expect(flat(said), answer).toContain(`start your 3-day free trial at ${CHECKOUT}`); + } + }); + + it("the plan page's wait: a Free choice made before it is not its answer; a stopped wait says it once", async () => { + signIn(); + const app = project({ id: "p1", plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: true, plan_url: `${HOST}/account/billing?project=p1` }); + const state = { account: { offer: { plan: { id: "team", name: "Pro" }, price: "$15", features: [], free: { name: "Free", apps: 1 }, checkoutUrl: CHECKOUT }, cli: null, plan: { id: "free", name: "Free" } }, projects: [{ ...app, needsPlan: false, planChosen: true, planUrl: app.plan_url, plan: app.plan }], app: { token: TOKEN } }; + const offer = upgradeOffer(state as never)!; + expect(offer).toMatchObject({ chosen: true, needs: false }); + const fetchFn = fakeServer({ "GET /api/cli/overview": { json: overview({ projects: [app] }) } }).fetchFn; + const said = await printed(async () => expect(await projectPlan(offer, { noBrowser: true }, { fetchFn, waitMs: 120, everyMs: 10 })).toBe("quit")); + expect(said).not.toContain("is on Free for now"); + expect(flat(said)).toContain("No choice yet."); + // the account's checkout, stopped: "No payment yet" once, not twice + type(""); + const pay = await printed(async () => expect(await startPro(cli, null, {}, { fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: LOCKED }) } }).fetchFn, waitMs: 60, everyMs: 10 })).toBe(false)); + expect(flat(pay).match(/starts once it is started on Stripe/g)).toHaveLength(1); + expect(pay).not.toContain("goes on from there"); + }); + + it("the coding agent's files are shown and asked about in the walk too; no writes them nothing", async () => { + const dir = tempDir(); + viteApp(dir); + type("n"); + const said = await printed(async () => expect(await connectAgent({ cwd: dir }, { confirmed: true })).toBe("skipped")); + expect(said).toContain(".mcp.json"); + expect(said).toContain(".claude/skills/genclass/SKILL.md"); + expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + }); + + it("taken out and added again: the account's app of the same name is used, not a second one", async () => { + const dir = tempDir(); + viteApp(dir); + signIn(); + const state = { signedIn: true, cwd: dir, projects: [{ id: "p1", name: "shop", token: TOKEN }], steps: [{ id: "runtime", run: "npx genclass init" }] }; + // no app of that name: a new one, as before + expect(await sameApp({ ...state, projects: [{ id: "p2", name: "blog", token: TOKEN }] } as never, { yes: true })).toBeNull(); + // at a terminal it asks; no makes a new app + type("n"); + expect(await printed(async () => expect(await sameApp(state as never, {})).toBeNull())).toContain("Use your existing shop?"); + // --yes: that app's key is written, and nothing is sent + const again = await sameApp(state as never, { yes: true, noInstall: true }); + expect(again).toBeTypeOf("function"); + const calls: string[] = []; + vi.stubGlobal("fetch", async (u: string) => (calls.push(String(u)), new Response("{}", { status: 404 }))); + try { + await printed(async () => expect(await again!()).toBe(0)); + } finally { + vi.unstubAllGlobals(); + } + expect(readFileSync(join(dir, "src/genclass.config.js"), "utf8")).toContain(TOKEN); + expect(calls.filter((u) => /projects/.test(u))).toEqual([]); + }); +}); diff --git a/packages/cli/test/home.test.ts b/packages/cli/test/home.test.ts index f0c95bc4..220306db 100644 --- a/packages/cli/test/home.test.ts +++ b/packages/cli/test/home.test.ts @@ -178,8 +178,9 @@ describe("genclass: the walk leads into the home (src/setup.mjs)", () => { const said = await at58(async () => expect(await setup({ cwd: dir }, { home: async (o: Record) => (opened.push(o), 0) })).toBe(0)); expect(opened).toHaveLength(1); expect(opened[0]).toMatchObject({ cwd: dir, app: "shop" }); - expect(said).toContain("q later"); - expect(plainLines(said).join(" ")).toContain("The setup waits here: setup continues it."); + // the same key is called the same everywhere + expect(said).toContain("q stop"); + expect(plainLines(said).join(" ")).toContain("Setup stopped here. Type setup to go on with it."); // run from inside the home, the walk ends as it always did (no home inside the home) type("q"); const nested = await at58(async () => expect(await setup({ cwd: dir, home: false }, { home: async () => (opened.push({}), 0) })).toBe(0)); diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index 2ae6295f..03980a5b 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -98,7 +98,9 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("Plan Free"); expect(f).toContain("Apps 1 of 1"); expect(f).toContain("History 7 days"); - expect(f).toContain("Model runs 0 of 3 used today"); + // without Pro neither suggest nor ask runs: their model runs are not shown as Free's + expect(f).toContain("Model runs come with Pro (suggest and ask)"); + expect(f).not.toContain("0 of 3 used today"); expect(f).toContain("Not included visitor analytics, alerts to Slack, Sentry and others, the stored events as a file, private repositories"); // the paid plan's card, in the server's words, and (nobody at a terminal) where it starts: the genclass command is // Pro's, so the account's own checkout, its trial first @@ -129,15 +131,19 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("Sign out npx genclass logout"); expect(JSON.parse((await said(() => profile({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: true, plan: { id: "free" }, apps: [{ name: "Shop", plan: { id: "free" } }], upgrade_url: `${HOST}/account/billing?plan=pro&start=1`, cli: { paid: false } }); // upgrade: Pro's card and its checkout, for the account (the genclass command is Pro's) + // nobody at a terminal, nothing started: it ends as every gated command does (exit 1, and --json says needs_pro) const u = await said(() => upgrade(me.o, me.deps)); - expect(u.code).toBe(0); + 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(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`); // on Team: no line, nothing to upgrade sql.db.prepare("UPDATE projects SET plan = 'team', stripe_mode = 'live', subscription_id = 'sub_s', subscription_status = 'active' WHERE id = ?").run(me.app.id); expect(await freeLine(me.o, me.deps)).toBe(false); - expect(flat((await said(() => upgrade(me.o, me.deps))).text)).toContain("Nothing to upgrade here"); + expect(flat((await said(() => upgrade(me.o, me.deps))).text)).toContain("there is nothing to upgrade"); }); it("Team: the price, when it renews, the seats; a sign-in from before still gets the plan's name and what to do", async () => { @@ -171,7 +177,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const free = flat((await said(() => team(me.o, me.deps))).text); expect(free).toContain("Team 1 of 1 seat used"); expect(free).toContain("Free is for one person."); - expect(free).toContain("npx genclass plan"); + expect(free).toContain("npx genclass upgrade"); const no = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); expect(no.code).toBe(1); // the genclass command is Pro's: the invite, like every command, says where Pro starts @@ -193,6 +199,38 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(list).toContain(`${HOST}/account/billing`); }); + it("Pro with one seat (the checkout's default): a team means more seats, in Manage billing, never another Pro", async () => { + const { env, sql } = setup(LIVE); + const me = await account(env); + setPlan(sql, me.account.id, "team", { stripe_mode: "live", seats: 1, subscription_id: "sub_t000000002", subscription_status: "active", current_period_end: "2026-12-13T12:00:00.000Z" }); + const t = flat((await said(() => team(me.o, me.deps))).text); + expect(t).toContain("Team 1 of 1 seat used"); + expect(t).toContain("Your plan has one seat, yours."); + expect(t).toContain("Add seats in Manage billing, then invite your team: npx genclass team invite"); + expect(t).not.toContain("is for one person"); + expect(t).not.toContain("A team comes with Pro"); + // the invite the server refuses says the same, and upgrade says there is nothing to upgrade, with the seats + const no = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); + expect(no.code).toBe(1); + // genclass.dev's own words since #231 ("add a seat for each member in Manage billing"), or the command's + expect(flat(no.text)).toMatch(/add (seats|a seat for each member) in Manage billing/i); + expect(flat(no.text)).toContain(`${HOST}/account/billing`); + expect(flat(no.text)).not.toContain("A team comes with Pro"); + const nj = JSON.parse((await said(() => team({ ...me.o, args: ["invite"], json: true }, me.deps))).text); + expect(nj).toMatchObject({ ok: false }); + expect(nj.error).toMatch(/Manage billing/); + expect(nj.needs_pro).toBeUndefined(); + const u = flat((await said(() => upgrade(me.o, me.deps))).text); + expect(u).toContain("You are on Pro: there is nothing to upgrade."); + expect(u).toContain("More people on it: add seats in Manage billing"); + // in its trial, plan says it is a trial and when the first charge is + sql.db.prepare("UPDATE accounts SET subscription_status = 'trialing' WHERE id = ?").run(me.account.id); + resetOwnerCache(); + const p = flat((await said(() => plan(me.o, me.deps))).text); + expect(p).toContain("Trial until 2026-12-13. Then the first charge, $15 / developer / month, unless you cancel before."); + expect(p).not.toContain("Renews"); + }); + it("apps: the account's apps, a new one by name, and the plan's limit said with what starts more", async () => { const { env } = setup(LIVE); const me = await account(env); @@ -224,7 +262,8 @@ describe("genclass is Pro's: a view the server refuses with 402 says where Pro s expect(flat(v.text)).toContain(`The genclass command comes with Pro ($15 / developer / month): start your 3-day free trial at ${HOST}/account/billing?plan=pro&start=1`); expect(flat(v.text)).toContain("npx genclass upgrade"); expect(v.text).not.toContain("Could not read the dashboard"); - expect(JSON.parse((await said(() => analytics({ ...me.o, json: true }, me.deps))).text)).toEqual({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, checkout_url: `${HOST}/account/billing?plan=pro&start=1`, trial_days: 3, run: "npx genclass upgrade" }); + // the same needs_pro shape as the gate's (src/paywall.mjs needsProJson) + expect(JSON.parse((await said(() => analytics({ ...me.o, json: true }, me.deps))).text)).toMatchObject({ ok: false, error: "the genclass command comes with Pro", needs_pro: true, signed_in: true, plan: "Pro", checkout_url: `${HOST}/account/billing?plan=pro&start=1`, trial_days: 3, run: "npx genclass upgrade" }); }); }); diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index b0377c4b..264e2923 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -212,14 +212,25 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { it("a step skipped for this run is not the next one, and --json says what to run and what only a person can do", async () => { const dir = tempDir(); viteApp(dir); + // skipping the sign-in skips everything after Pro too: the genclass command is Pro's (CONTRACT: the gate's side doors) const s = await readState({ cwd: dir, skip: ["account"] }, { runGit: noGit }); - expect(s.next?.id).toBe("runtime"); - const j = stateJson(s); + 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"); + // 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; + const p = await readState({ cwd: dir, skip: ["runtime"] }, { runGit: noGit, fetchFn }); + expect(p.next?.id).toBe("agent"); + const pro = await readState({ cwd: dir }, { runGit: noGit, fetchFn }); + expect(pro.next?.id).toBe("runtime"); + const j = stateJson(pro); expect(j.next).toMatchObject({ id: "runtime", title: "Add GenClass to this app", run: "npx genclass init", human: null }); // every step has a short name, what doing it is called, one sentence and a fuller one - for (const x of s.steps) expect([x.title.length > 0, x.doing.length > 0, x.why.length > 0 && x.why.length < 100, x.more.length > 0], x.id).toEqual([true, true, true, true]); + for (const x of [...s.steps, ...pro.steps]) expect([x.title.length > 0, x.doing.length > 0, x.why.length > 0 && x.why.length < 100, x.more.length > 0], x.id).toEqual([true, true, true, true]); expect(j.steps.map((x) => x.id)).toEqual(["account", "plan", "runtime", "app-plan", "dashboard", "workflow", "github-app", "agent", "suggestions"]); - expect(j.steps[0]).toMatchObject({ state: "todo", run: "npx genclass login", human: "approve the sign-in in your browser" }); + expect(stateJson(s).steps[0]).toMatchObject({ state: "todo", run: "npx genclass login", human: "approve the sign-in in your browser" }); expect(j.complete).toBe(false); expect(JSON.stringify(j)).not.toContain(SESSION); }); @@ -341,6 +352,7 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s const CHECKOUT = `${HOST}/account/billing?plan=pro&start=1`; const LOCKED = { paid: false, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT }; const PRO = { plan: { id: "team", name: "Pro" }, cli: { paid: true } }; + const NEEDS_PRO = { ok: false, error: "the genclass command comes with Pro", needs_pro: true, signed_in: true, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT, run: "npx genclass upgrade" }; const plain = (t: string) => t.replace(/\u001b\[[0-9;]*m/g, ""); const flat = (t: string) => plain(t).replace(/\s+/g, " "); @@ -365,9 +377,14 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s const paid = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview(PRO) } }).fetchFn }); expect(paid.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "Pro" }); expect(paid.next?.id).toBe("runtime"); - // a member of a paying team - const member = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { paid: true } }) } }).fetchFn }); + // a member of a paying team (the server says why: via "team") + const member = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { paid: true, via: "team" } }) } }).fetchFn }); expect(member.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "through your team's plan" }); + // a server that sells nothing here (via "open"), or does not say why: never "your team's plan" + const open = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { paid: true, via: "open" } }) } }).fetchFn }); + expect(open.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "open on this genclass.dev" }); + const unsaid = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { paid: true } }) } }).fetchFn }); + expect(unsaid.steps.find((x) => x.id === "plan")!.detail).not.toContain("team"); // a server that does not say (older, self-hosted) locks nothing const older = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview() } }).fetchFn }); expect(older.steps.find((x) => x.id === "plan")).toMatchObject({ state: "optional" }); @@ -388,7 +405,8 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s // the gate every command passes: the same line, exit code 1; --json says it as data forgetPro(); expect(flat(await printed(async () => expect(await requirePro({ yes: true }, { fetchFn })).toBe(1)))).toContain(`start your 3-day free trial at ${CHECKOUT}`); - expect(JSON.parse(await printed(async () => expect(await requirePro({ json: true }, { fetchFn })).toBe(1)))).toEqual({ ok: false, needs_pro: true, plan: "Pro", price: "$15 / developer / month", trial_days: 3, checkout_url: CHECKOUT }); + // one shape for needs_pro, wherever it comes from (the gate, a route's 402, upgrade) + expect(JSON.parse(await printed(async () => expect(await requirePro({ json: true }, { fetchFn })).toBe(1)))).toEqual(NEEDS_PRO); // Pro: in, and not asked again in this process const paid = fakeServer({ "GET /api/cli/overview": { json: overview(PRO) } }); expect(await requirePro({ yes: true }, { fetchFn: paid.fetchFn })).toBe(0); @@ -413,10 +431,19 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s forgetPro(); const dir = tempDir(); viteApp(dir); - for (const cmd of ["dashboard", "problems", "suggest", "connections", "export", "init", "scan"]) { + for (const cmd of ["dashboard", "problems", "suggest", "connections", "export", "init"]) { const said = await printed(async () => expect(await run([cmd, "--yes", "--cwd", dir]), cmd).toBe(1)); expect(flat(said), cmd).toContain("Sign in first: npx genclass login. The genclass command comes with Pro"); } + // the home's commands pass the gate too (the home opens on q before the sign-in) + const nested = await printed(async () => expect(await run(["suggest", "--no-model", "--yes", "--cwd", dir], { nested: true })).toBe(1)); + expect(flat(nested)).toContain("Sign in first: npx genclass login"); + // a scan that did not run ends with 2 (its 1 means "problems found"), and says where the page check is free + for (const cmd of ["scan", "audit"]) { + const said = await printed(async () => expect(await run([cmd, "https://example.com", "--cwd", dir]), cmd).toBe(2)); + expect(flat(said), cmd).toContain("Sign in first: npx genclass login"); + expect(flat(said), cmd).toContain("Free, without Pro: the page check on the website https://genclass.dev/scan"); + } // where things stand is not gated: it shows Pro as the step after the sign-in expect(JSON.parse(await printed(async () => expect(await run(["status", "--json", "--cwd", dir])).toBe(0))).steps.map((x: { id: string }) => x.id).slice(0, 2)).toEqual(["account", "plan"]); // a word a command does not take is told so before anything is asked of genclass.dev @@ -470,12 +497,25 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s const app = (extra: object) => project({ id: "p1", plan: { id: "team", name: "Pro", source: "paid" }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); const read = (p: object) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ ...PRO, projects: [p] }) } }).fetchFn }); // nobody at a terminal here: it says where the page is and goes on + // an app Pro covers has a plan: the line says so (it continues once on its page), and nothing waits on it let r = ""; const said = await printed(async () => (r = await planNow({ cwd: dir }, { readState: async () => read(app({})) }))); - expect(r).toBe("open"); - expect(flat(said)).toContain(`shop has no plan yet. Choose it in the browser: ${url}&from=cli`); + expect(r).toBe("covered"); + expect(flat(said)).toContain(`shop is covered by your Pro plan. Continue with it once on its plan page: ${url}&from=cli`); + expect(flat(said)).not.toContain("has no plan yet"); // the app named by its id, the same - expect(await planNow({ cwd: dir }, { readState: async () => read(app({})) }, "p1")).toBe("open"); + expect(await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(app({})) }, "p1")).toBe("covered"))).toContain("covered by your Pro plan"); + // a fourth app on Pro: nothing to pay for (more apps are Enterprise's), so nothing is opened or waited for + const fourth = app({ plan: { id: "free", name: "Free", source: null }, needs_plan: true }); + const locked = await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(fourth) })).toBe("locked")); + expect(flat(locked)).toContain("Your plan covers no more apps, and shop is one more. More apps come with Enterprise, by email."); + expect(flat(locked)).toContain(`${url}&from=cli`); + expect(locked).not.toMatch(/Waiting|No payment yet|has no plan yet/); + // on Free, an app with no plan yet: it is chosen in the browser + const freeApp = project({ id: "p1", plan: { id: "free", name: "Free", source: null }, needs_plan: false, plan_chosen: false, plan_url: url }); + const onFree = (p: object) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ offer: OFFER, cli: LOCKED, projects: [p] }) } }).fetchFn }); + const freeSaid = await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => onFree(freeApp) })).toBe("open")); + expect(flat(freeSaid)).toContain(`shop has no plan yet. Choose it in the browser: ${url}&from=cli`); // nothing to choose: Continue clicked, or GenClass's grant continued, or a team's app for (const p of [app({ plan_chosen: true }), app({ plan: { id: "team", name: "Pro", source: "comp" }, plan_chosen: true }), project({ shared: true })]) { expect(await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(p) })).toBe("none"))).toBe(""); diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 5302a266..14146cbb 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -4,7 +4,11 @@ - 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). + free). Run by `genclass`, whose commands come with Pro, it names the terminal's own dashboard instead, and a member + of a paying team is told a new app goes into the team's account. `whoami` and `login` no longer say every dashboard + is free. +- **Narrow terminals:** `init`'s install command folds between words instead of breaking one, the telemetry address + is on its own line, and the session file is shown with `~` for the home folder. - **`init`/`connect` recognise more existing setups:** `@genclass/nuxt` and `@genclass/astro` in a config, and the key of a hand-written setup (`bin/lib/init.mjs` `inspectProject`). `connect --yes` refuses to replace a key that is diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 16b7acd3..a600a5ce 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -417,7 +417,7 @@ observing with no download and no worker. classic script, which the model's worker cannot be, so `vite build` stops with `Invalid value "iife" for option "worker.format"`; and the dev server's pre-bundle has no worker file, so the model runs on the page's main thread with a warning on each load. Add `worker: { format: "es" }` (the build) and - `optimizeDeps: { exclude: ["@genclass/runtime"] }` (the dev server) to your Vite config. `npx genclass` writes both + `optimizeDeps: { exclude: ["@genclass/runtime"] }` (the dev server) to your Vite config. `npx genclass` (with Pro) writes both for you and takes them out again; `init` in this package does not yet. Vite 8 needs neither. - **The model is new and imperfect.** It misses most problems, it was not evaluated on your app, and in observe mode it makes some false flags ([Model quality](#model-quality)). It is not a substitute for tests. Watch observe mode @@ -837,11 +837,13 @@ writes), the meta tag or the script tag. digits) that groups its diagnostics on a dashboard at genclass.dev, kept in a GenClass account (the CLI's `login`, then `connect`, `whoami`, `logout`) or behind one private link per app (anyone with the link can open it), which `init` saves in `.genclass.local` and `npx @genclass/runtime@beta dashboard` prints again (from `0.3.0-beta.2`; -`--open` opens it). The dashboard is free for every app (what GenClass found, where and what it did, for the last 7 -days and up to 3 apps in an account), and so is the `@genclass/mcp` server for coding agents. Visitor analytics, -forwarding, export, 90 days of history and team members come with the Team plan, US$15 a developer a month; your own -classifier, which a person from GenClass trains, and a year of history with Enterprise -([pricing](https://genclass.dev/pricing)). No plan limits the runtime. It needs +`--open` opens it). On Free the dashboard shows an app's totals for the last 7 days, for one app in an account, and +the `@genclass/mcp` server for coding agents is free. Pro, US$15 a developer a month for up to 3 apps in an account, +adds every problem with its pages and a fix prompt, visitor analytics, forwarding, export, 90 days of history, team +members and the `genclass` command; more apps, your own classifier, which a person from GenClass trains, and a year +of history come with Enterprise ([pricing](https://genclass.dev/pricing)). Plans bought before 13 November 2026 keep +every app they cover (an app paid for on its own does not count toward Pro's 3); Free covers an account's oldest app +and locks the others. No plan limits the runtime, and it needs no account. It needs telemetry on and a deployed host: local hosts do not send by default. Pass a key with `GenClass.init({ token })`, `data-token`, `token=` in the meta tag or `window.GENCLASS_CONFIG = { token }`. Accounts, the private link and what the CLI sends: [DASHBOARD.md](https://github.com/genclass-dev/GenClass/blob/main/docs/runtime/DASHBOARD.md). diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs index 5ac32ef9..19116739 100644 --- a/packages/runtime/bin/lib/init.mjs +++ b/packages/runtime/bin/lib/init.mjs @@ -30,11 +30,17 @@ export const INTERCEPTION_URL = "https://genclass.dev/docs/interception"; export const PRIVACY_URL = `${REPO}/PRIVACY.md`; /** The plans page: forwarding to Sentry, PostHog and Datadog is not part of the free runtime; the genclass.dev collector does it on the Team and Enterprise plans. */ export const PRICING_URL = "https://genclass.dev/pricing"; -/** What a dashboard shows, said under every dashboard link the CLI prints: Free the totals, Pro the rest (since 2026-11-12). */ -export const FREE_LINES = [ - "Free shows this app's totals for the last 7 days. Every problem, the pages it happens on, a fix prompt for", - `each and visitor analytics come with Pro (3 days free): ${PRICING_URL}. This link again: ${CMD} dashboard`, -]; +/** + * What a dashboard shows, said under every dashboard link the CLI prints: Free the totals, Pro the rest (since + * 2026-11-12). Through a host CLI (`genclass`, whose commands come with Pro: init and connect run there only once + * the account has it) the lines do not offer Pro again: they say where the rest is. + */ +export const FREE_LINES = HOST_CLI + ? ["Every problem, the pages it happens on, a fix prompt for each and visitor analytics, in the terminal:", `${CMD} dashboard`] + : [ + "Free shows this app's totals for the last 7 days. Every problem, the pages it happens on, a fix prompt for", + `each and visitor analytics come with Pro (3 days free): ${PRICING_URL}. This link again: ${CMD} dashboard`, + ]; export const USAGE = `Usage: genclass-runtime init [--mode observe|guard|heal] [--token | --no-token] @@ -418,7 +424,29 @@ function briefTelemetry(cfg) { briefLines("Anonymous diagnostics are on: counts and decisions go to GenClass's maintainers, with no page text, cookies or IP addresses, and never from localhost."); // the command and the address each stay whole on a line of their own, so they can be copied out(` ${c.gray(`Off: ${CMD} init --no-telemetry`)}`); - out(` ${c.gray(`What is sent: ${TELEMETRY_URL}`)}`); + out(` ${c.gray("What is sent:")}`); + out(` ${c.gray(TELEMETRY_URL)}`); +} + +/** + * A command shown as what will run: on its label's line when it fits the window, else under it, folded between its + * words (a narrow terminal would otherwise break a word of it in two, "--n" and "o-audit"). + */ +function commandLine(label, cmd) { + const cols = Math.max(40, Math.min(100, Number(process.stdout.columns) || 100)); + if (2 + label.length + 2 + cmd.length <= cols) { + out(` ${c.bold(label)} ${c.cyan(cmd)}`); + return; + } + out(` ${c.bold(label)}`); + let line = ""; + for (const w of cmd.split(" ")) { + if (line && 4 + line.length + 1 + w.length > cols) { + out(` ${c.cyan(line)}`); + line = w; + } else line = line ? `${line} ${w}` : w; + } + if (line) out(` ${c.cyan(line)}`); } /** A grey sentence of the compact (--brief) output, folded to the window (40 to 100 columns), every line indented. */ @@ -527,7 +555,7 @@ async function switchModes(o, project, planned, install, spec, cwd, what, tk) { out(` ${c.bold("Changes")}`); out(); for (const ch of changes) printChange({ ...ch, file: ch.rel }); - if (install) out(` ${c.bold("Install")} ${c.cyan(installArgs(project.pm, spec).flat().join(" "))}`); + if (install) commandLine("Install", installArgs(project.pm, spec).flat().join(" ")); out(); if (o.dryRun) { out(` ${c.gray("Dry run: nothing was written.")}`); @@ -658,13 +686,20 @@ async function settleToken(tk, changes, replan) { /** "dev@example.com" (or the GitHub login, or the account id) for a saved session. */ export const accountLabel = (account) => account?.email || (account?.github_login ? `GitHub ${account.github_login}` : account?.id ? `account ${account.id}` : "your account"); +/** + * Where a new app of the signed-in account goes, in words: "your account (dev@example.com)". A host CLI that knows + * better says so through HOST_CLI.into (`genclass`: a member of a paying team with no plan of their own adds apps to + * the team's account, which genclass.dev puts them in). + */ +export const accountWhere = (account) => (typeof HOST_CLI?.into === "string" && HOST_CLI.into ? HOST_CLI.into : `your account (${accountLabel(account)})`); + /** The command running init's flow ("init", or "connect"), for the messages that say what it will send. */ let actor = "init"; 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 your account (${accountLabel(tk.account.account)}) ${c.gray("once you apply these changes")}`); + 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.`); return; @@ -676,7 +711,7 @@ function tokenRow(tk) { return; } if (tk.token) { - const from = tk.source === "new" ? "created for this app at genclass.dev" : tk.source === "account" ? `a new app in your account (${accountLabel(tk.account?.account)})` : tk.source; + 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"}.`); return; @@ -738,7 +773,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 your GenClass account (${accountLabel(tk.account?.account)}): sign in at genclass.dev to open it.`)}`); + 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.`)}`); 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")}`); @@ -968,7 +1003,7 @@ export async function init(o, ctx = {}) { briefTelemetry(cfg); } else telemetryNotice(cfg, cfgAt); if (!ctx.creds) tk = await offerAccount(o, tk); - if (brief && tk.pending && tk.account) briefLines(`The app goes into your account (${accountLabel(tk.account.account)}) as "${String(tk.name ?? "").slice(0, 80) || "unnamed"}".`); + if (brief && tk.pending && tk.account) briefLines(`The app goes into ${accountWhere(tk.account.account)} as "${String(tk.name ?? "").slice(0, 80) || "unnamed"}".`); else tokenRow(tk); sinksNotice(cfg, cfgAt); cspAdvice(project, files, cfg); @@ -976,7 +1011,7 @@ export async function init(o, ctx = {}) { out(` ${c.bold("Changes")}`); out(); for (const ch of plan.changes) printChange({ ...ch, file: ch.rel }); - if (install) out(` ${c.bold("Install")} ${c.cyan(installArgs(project.pm, spec).flat().join(" "))}`); + if (install) commandLine("Install", installArgs(project.pm, spec).flat().join(" ")); else if (needsPackage) out(` ${c.yellow(sym.warn)} --no-install: ${older ? `upgrade ${PKG} yourself (${installArgs(project.pm, spec).flat().join(" ")})` : `add ${PKG} to your dependencies yourself`}.`); for (const n of plan.notes ?? []) out(` ${c.yellow(sym.warn)} ${n}`); out(); diff --git a/packages/runtime/bin/lib/login.mjs b/packages/runtime/bin/lib/login.mjs index c146563c..baacdbd9 100644 --- a/packages/runtime/bin/lib/login.mjs +++ b/packages/runtime/bin/lib/login.mjs @@ -8,13 +8,14 @@ // approved, and writes its token exactly the way init does (init.mjs: the same snippet edits, the same diff and // question; --yes skips it). An app init made without an account (its private link in .genclass.local) can be added // to the account instead of replaced. whoami prints the account and its apps; logout revokes the session at -// genclass.dev and deletes the file. dashboard prints the link to the free dashboard of the app in this folder: its +// genclass.dev and deletes the file. dashboard prints the link to the dashboard of the app in this folder: its // private link from .genclass.local (no network), else its page in the signed-in account (GET /api/me), else the // account's apps; --open opens it. Running one of these commands is the agreement to contact genclass.dev for it; // creating an app or writing a file is asked first. A private dashboard link is only ever sent to the host it belongs // to: with GENCLASS_HOST set to another host, a genclass.dev link in .genclass.local stays on this computer. import { spawn } from "node:child_process"; +import { homedir } from "node:os"; import { relative } from "node:path"; import { claimProject, @@ -54,10 +55,10 @@ connect connects the app in this folder to your account: pick one of your apps, whoami shows the signed-in account and its apps. logout ends the CLI session at genclass.dev and deletes the saved file. dashboard - prints the link to this app's dashboard, free for every app: what GenClass found, on which pages - and what it did (Free shows the totals; every problem, its pages and how visitors use each page come with the Pro plan). It reads the private link in - ${LOCAL_FILE} without contacting anything; signed in, it finds the app in your account (or lists - your apps). + prints the link to this app's dashboard: what GenClass found, on which pages and what it did + (Free shows one app's totals; every problem, its pages and how visitors use each page come + with the Pro plan). It reads the private link in ${LOCAL_FILE} without contacting anything; + signed in, it finds the app in your account (or lists your apps). --yes, -y no questions: login connects this folder's app; connect uses your app named like package.json (or creates it) and applies the changes @@ -105,6 +106,18 @@ const onHost = (url, host) => { } }; +/** A file's path as a person writes it: the home folder as ~ (the whole path does not fit a narrow window). */ +const tilde = (path) => { + const home = homedir(); + return home && typeof path === "string" && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +}; + +/** + * Where an app is set up from here: this CLI's connect, or a host CLI's own walk (`npx genclass`), which shows its + * plan before it connects anything. + */ +const SETUP_CMD = HOST_CLI ? CMD : `${CMD} connect`; + const sessionGone = () => err(` ${c.red(sym.cross)} The saved CLI session no longer works (signed out, revoked or expired). Sign in again: ${c.cyan(`${CMD} login`)}`); /** Opens url in the default browser when that makes sense here. Returns whether it tried; never throws. */ @@ -302,7 +315,7 @@ async function connectApp(o, creds, st, me) { out(` Its private link stays in ${LOCAL_FILE}; to keep that history, paste it under "Add an existing dashboard" at ${hostUrl("/account")}.`); out(); } - row("App", app ? `${c.bold(app.name)} ${c.gray(`(in your account; project key ${app.token})`)}` : `${c.bold(createName)} ${c.gray("(a new app in your account, created once you apply the changes)")}`); + row("App", app ? `${c.bold(app.name)} ${c.gray(`(in your account; project key ${app.token})`)}` : `${c.bold(createName)} ${c.gray(`(a new app in ${HOST_CLI?.into ? HOST_CLI.into : "your account"}, created once you apply the changes)`)}`); if (st.handWritten) { out(); @@ -367,7 +380,7 @@ async function afterLogin(o, creds, me) { 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.`); - out(` ${c.gray(`Your apps' dashboards, free: ${hostUrl("/account")}`)}`); + out(` ${c.gray("Your apps' dashboards:")} ${c.cyan(hostUrl("/account"))}`); out(); return 0; } @@ -388,6 +401,35 @@ async function afterLogin(o, creds, me) { return connectApp(o, creds, st, me); } +/** + * The question login asks after the sign-in, on its own: connect this folder's app (asked first; --yes connects). + * For a host CLI (`genclass`) that runs `login --no-connect` and shows its plan in between. `argv`: login's own + * arguments. Returns the exit code. + */ +export async function offerConnect(argv) { + let o; + try { + o = parse(argv.filter((a) => a !== "--no-connect"), SPECS.login); + } catch (e) { + if (e instanceof UsageError) return 2; + throw e; + } + const { creds } = readCredentialsFor(); + if (!creds) return 1; + return afterLogin(o, creds, null); +} + +/** What is wrong with a command's arguments, in its usage error's words, or null when they are right (no network). */ +export function argsError(cmd, argv) { + try { + const o = parse(argv, SPECS[cmd]); + return o.app !== undefined && o.name !== undefined ? "--app and --name contradict each other (use an app, or create one)" : null; + } catch (e) { + if (e instanceof UsageError) return e.message; + throw e; + } +} + async function login(o) { banner("login"); hostNotice(); @@ -460,7 +502,8 @@ async function login(o) { } out(); row("Signed in", `${c.bold(accountLabel(creds.account))}${creds.account.github_login && creds.account.email ? c.gray(` (GitHub ${creds.account.github_login})`) : ""}`); - out(` ${c.gray(`Session saved in ${path} (only you can read it). Sign out: ${CMD} logout`)}`); + out(` ${c.gray(`Session saved in ${tilde(path)} (only you can read it).`)}`); + out(` ${c.gray(`Sign out: ${CMD} logout`)}`); out(); return afterLogin(o, creds, null); } @@ -485,15 +528,17 @@ async function whoami() { } const a = me.account; row("Account", `${c.bold(accountLabel(a))}${a.github_login && a.email ? c.gray(` (GitHub ${a.github_login})`) : ""}${a.id ? c.gray(` id ${a.id}`) : ""}`); - row("Session", c.gray(`${path}${when(creds.created) ? `, signed in ${when(creds.created)}` : ""}`)); + row("Session", c.gray(`${tilde(path)}${when(creds.created) ? `, signed in ${when(creds.created)}` : ""}`)); out(); out(` ${c.bold("Apps")} ${c.gray(`(${me.projects.length})`)}`); - if (!me.projects.length) out(` None yet. In your app's folder: ${c.cyan(`${CMD} connect`)}`); + 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()); } - if (me.projects.length) out(` ${c.gray(`Each app's dashboard is free: open its link above, or run ${CMD} dashboard in the app's folder.`)}`); + // 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.`)}`); out(); return 0; } @@ -538,7 +583,7 @@ async function dashboard(o) { } if (st.token) out(` This folder's project key (${st.token}) is not one of your account's apps; add it with ${c.cyan(`${CMD} connect`)}.`); out(` ${c.bold("Your apps")} ${c.bold(c.cyan(hostUrl("/account")))}`); - if (!me.projects.length) out(` None yet. In your app's folder: ${c.cyan(`${CMD} connect`)}`); + 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.dashboard_url ? c.cyan(p.dashboard_url) : ""}`.trimEnd()); for (const l of FREE_LINES) out(` ${c.gray(l)}`); @@ -576,12 +621,13 @@ async function logout() { const r = await revokeSession(creds); deleteCredentials(); if (!r.ok) { - err(` ${c.yellow(sym.warn)} Could not end the session at ${apiHost()} (${r.reason}). ${path} is deleted.`); + err(` ${c.yellow(sym.warn)} Could not end the session at ${apiHost()} (${r.reason}). ${tilde(path)} is deleted.`); err(` End it yourself at ${c.cyan(hostUrl("/account"))} (CLI sessions, Revoke).`); out(); return 1; } - row("Signed out", `${accountLabel(creds.account)} ${c.gray(`(the CLI session is ended at genclass.dev and ${path} is deleted)`)}`); + row("Signed out", accountLabel(creds.account)); + out(` ${c.gray(`The CLI session is ended at genclass.dev, and ${tilde(path)} is deleted.`)}`); out(); return 0; }