diff --git a/AGENTS.md b/AGENTS.md index a3e15317..1e274415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,10 +136,15 @@ Azure, `git push`, `npm publish`. digest in `digest.mjs` (failed checks with at most three evidence lines each, and the app's name, framework, address, payment provider and host): no source code, no file contents, no secret's value (a check that finds a key reports its kind, file and line). It is shown before the first send for a project, `--no-model` sends nothing, and - the server stores a count per account and day and nothing else. Sending more than the digest holds, storing a - digest or an answer, or changing who the digest goes to needs the owner's OK and a change to PRIVACY.md, like - rule 8. The checks are free for everyone; a plan decides how many items of the model's plan are shown and how - many runs a day (`billing/plans.json` `suggestions`). + the server stores a count per account and day. Changed by the owner on 2026-11-12: the digest also holds what the + site says it is about (its title and meta description, 300 characters); besides ordering the checks, the model + writes its own ideas for the app and website design ideas, each inspired by a real site it names (the server drops + one whose site does not answer), always printed as the model's ideas, never as measured; and the last answer per + account and app is kept (`suggest_results`, migration 0019) so that Free is shown the same answer again until it + pays, and Pro reads it. Free (`suggestions.shown` 1) sees the first item and the counts of the rest, ideas and + designs included, which are never sent to it. Storing the digest, sending more than it holds, or changing who it + goes to needs the owner's OK and a change to PRIVACY.md, like rule 8. The measured checks run for everyone; a plan + decides how many items of the answer are shown and how many runs a day (`billing/plans.json` `suggestions`). 12. **A terminal does what its approval page lists, and `genclass ask` sends a question, never code** (`telemetry-worker/src/cli.ts`, `src/ask.ts`, `src/account-pages.ts`; owner request, 2026-11-12). Every route under @@ -151,7 +156,9 @@ Azure, `git push`, `npm publish`. deletes an app or the account. `genclass ask` sends the question and at most three earlier turns; the server adds a digest of the numbers that account's dashboard shows (`appDigest`) and gives both to the model, which is told to answer from the numbers only and that page names and labels are data. Nothing of a question or an answer is - stored or logged, and it takes one of the plan's model runs a day, shared with `suggest`. `genclass fix` builds + stored or logged, and it takes one of the plan's model runs a day, shared with `suggest`. Since 2026-11-12 asking + is Pro's (`billing/plans.json` entitlement `ask`), and Free sees the totals only (entitlement `problems`: the + problem lists, pages, activity and fix prompts are left out of its stats, `src/dashboard.ts` stripProblems). `genclass fix` builds its prompt on the person's machine and starts their own agent on a key press: nothing in this repository fixes code by itself. Sending the model more than the digest holds, storing a question or an answer, or letting a terminal do something the approval page does not list needs the owner's OK and a change to PRIVACY.md. diff --git a/PRIVACY.md b/PRIVACY.md index 9a0bda2d..87e29851 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -142,7 +142,7 @@ and invite links. ## Paid plans, payments and Stripe -Free costs nothing, Team 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 per project (since 12 November 2026; per developer from 11 November), 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 until 22 October 2026) is paid through Stripe, which processes payments @@ -243,7 +243,7 @@ its export already hold; nothing new is collected or stored for them. A destinat `genclass connections add` is not shown, is sent to genclass.dev once over HTTPS, is stored encrypted there like one typed on the dashboard, and is not kept on the developer's machine. -**`genclass ask`** answers a question about an app in words. The question, and at most three earlier questions and +**`genclass ask`** (the Pro plan) answers a question about an app in words. The question, and at most three earlier questions and answers of the same sitting, leave the developer's machine. On genclass.dev we add the numbers that account's dashboard already shows for the app and period (page paths, kinds of problem, counts, what the runtime did, what the coding agent reported and, where the plan has visitor analytics, visits, sources, devices, countries and the names @@ -269,15 +269,19 @@ repository answers "not found" and is left as it is. should not exist, and DNS lookups for its mail records. They go to that site, not to us. - **A digest sent to genclass.dev, to get a plan written for the app.** It holds the checks that failed (each with its title and at most three short evidence lines, such as a status code, a page title or a file name and line - number) and five things about the app: its name, framework, address, payment provider and host. It holds no source + number), and six things about the app: its name, framework, address, what its site says it is about (the home + page's title and description, at most 300 characters), payment provider and host. It holds no source code, no file contents, no environment values and no secrets; where a check finds a key in a file, the evidence is the kind of key, the file and the line, never the key. The command shows this before the first time it sends it for a project, and `--no-model` sends nothing. -We pass the digest to a language model (OpenAI's API) to order the failed checks and word the reasons, and return -its answer. We do not store the digest or the answer and do not log them. We keep one number: 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. +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. +OpenAI processes the digest under its API terms. Nothing from a digest is used to train GenClass's own model. ## Where it goes and how long we keep it diff --git a/billing/plans.json b/billing/plans.json index 1411b3a4..d1baec05 100644 --- a/billing/plans.json +++ b/billing/plans.json @@ -1,40 +1,40 @@ { - "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. `trial_days` (Team, 3 since 2026-11-12): the free days a Checkout gives an account whose Stripe customer has never had a subscription (a card is still taken up front; the first month is charged when the trial ends unless cancelled); any later Checkout has none. Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone.", + "_about": "The one place GenClass plans are defined. telemetry-worker/src/plans.ts imports this file (limits, entitlements, Stripe lookup keys, the billing page) and genclass.dev's site reads it (the pricing page). Three plans since version 3 (2026-11-11): Free, Team and Enterprise; since version 4 (2026-11-12) a plan belongs to a project, not to an account. Free is what finds, shows and fixes the problems: the open-source runtime with every mode and its on-device model, the coding-agent loop on the developer's machine, the dashboard's problems for an account's first 3 projects (the last 7 days shown; a 4th project on Free is locked until it has a plan), and 100 problems fixed live a month per project (limits.live_fixes_per_month; past it the runtime is told to observe only until the month ends) and genclass-dev[bot] on public repositories. Team is sold per project (per_project true: one Stripe subscription of quantity 1 per project, its metadata naming the project; everyone on the account's team sees it; telemetry-worker/src/stripe.ts) and adds what a team uses: genclass-dev[bot] on private repositories, visitor analytics, forwarding, export, 90 days of history and members. Enterprise is agreed by email: a null monthly price means the plan is not sold on the site, and an account gets it from GenClass's own grant (accounts.comp_until, set by hand to the end of the agreement; accounts.comp_plan names the plan granted, grant_plan when it is empty). The classifier a person from GenClass trains is in Enterprise only. retired_plans lists the plan ids GenClass sold before (Pro and Custom until 2026-10-19, Individual until 2026-10-22): their Stripe products and prices stay GenClass's own, so a subscription to one is still cancelled with its account, opens Manage billing and is reported to the owner while it bills, but it gives no plan (it reads as Free). The worker's Stripe code and scripts/stripe-setup.mjs sell any plan given a monthly price above 0, its stripe_lookup_keys and a stripe_tax_code. Prices are US cents (per project for a per_project plan, per seat for a per_seat plan); a null yearly price means no yearly option. limits.apps null means no plan limit (the worker still caps an account at 200 apps against abuse); limits.seats is how many people a plan that is not per_seat holds (null: no limit); limits.history_days is what the dashboard shows, and data is never deleted before 90 days whatever the plan, so the longer history is there the moment an account upgrades. Ingest never depends on the plan: an app's visitor analytics are counted while its plan leaves them out, and shown once it includes them. Pro (id team; named Team until 2026-11-12) has a trial: trial_days (3) days free at Checkout, a card taken up front and charged when it ends unless cancelled, once per account (telemetry-worker/src/billing.ts trialFor). Feature lines with available false are shown as coming next. The entitlement github_private is what lets genclass-dev[bot], the GitHub App, review an account's private repositories (telemetry-worker/src/github/entitlement.ts); public repositories are reviewed for everyone, whatever this file says. A self-hosted collector sets PLAN_GATES = \"open\" and gives every account the grant plan. managed_payments true would sell a plan through Stripe Managed Payments (only for fully automated digital products; telemetry-worker/README.md, Paid plans). `suggestions` (2026-11-12): what `genclass suggest` gives a plan (telemetry-worker/src/suggest.ts): `per_day`, model runs an account has a day, and `shown`, how many items of the plan it is shown (null: all); the measured checks themselves are free for everyone. Entitlements `problems` (the problem lists, pages, activity and fix prompts on the dashboard and in the genclass command; Free shows only the totals) and `ask` (genclass ask) were added 2026-11-12 (the owner's 'limit free fully'); a plan file without the key gives them.", "version": 4, "currency": "usd", "contact": "mehar@genclass.dev", "grant_plan": "enterprise", "retired_plans": ["pro", "custom", "individual"], - "footer": "Questions about Team, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev.", + "footer": "Questions about Pro, Enterprise, your own classifier, self-hosting or invoices? Email mehar@genclass.dev.", "plans": [ { "id": "free", "name": "Free", - "summary": "Find, see and fix the problems. Up to 3 projects.", + "summary": "The totals, one suggestion, up to 3 projects.", "price_usd_cents": { "monthly": 0, "yearly": null }, "stripe_lookup_keys": { "monthly": null, "yearly": null }, "stripe_tax_code": null, "managed_payments": false, "per_seat": false, "limits": { "apps": 3, "history_days": 7, "seats": 1, "live_fixes_per_month": 100 }, - "suggestions": { "per_day": 3, "shown": 3 }, - "entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false }, + "suggestions": { "per_day": 3, "shown": 1 }, + "entitlements": { "dashboard": true, "forwarding": false, "export": false, "analytics": false, "custom_model": false, "github_private": false, "problems": false, "ask": false }, "features": [ { "text": "Open-source runtime, every mode", "available": true }, { "text": "On-device model", "available": true }, { "text": "Coding-agent loop on your machine (@genclass/mcp)", "available": true }, - { "text": "Dashboard: the problems found, last 7 days", "available": true }, + { "text": "Dashboard totals, last 7 days", "available": true }, { "text": "Up to 3 projects", "available": true }, { "text": "100 live fixes a month per project, then observe only", "available": true }, { "text": "GitHub App on public repositories", "available": true }, { "text": "Page check", "available": true }, - { "text": "Suggestions: your top 3 next steps", "available": true }, + { "text": "Suggestions: your top one", "available": true }, { "text": "Self-host the collector", "available": true } ] }, { "id": "team", - "name": "Team", + "name": "Pro", "summary": "Everything for one project, your whole team included.", "price_usd_cents": { "monthly": 1500, "yearly": null }, "stripe_lookup_keys": { "monthly": "genclass_team_monthly", "yearly": null }, @@ -44,11 +44,15 @@ "trial_days": 3, "limits": { "apps": null, "history_days": 90, "seats": null, "live_fixes_per_month": null }, "suggestions": { "per_day": 30, "shown": null }, - "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": false, "github_private": true }, + "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": false, "github_private": true, "problems": true, "ask": true }, "features": [ { "text": "Everything in Free", "available": true }, + { "text": "3 days free to start", "available": true }, + { "text": "Every problem, its pages and a fix prompt", "available": true }, + { "text": "Ask about your app in plain words", "available": true }, + { "text": "Every suggestion, plus the model's own ideas", "available": true }, + { "text": "Website design ideas from sites like yours", "available": true }, { "text": "Unlimited live fixes", "available": true }, - { "text": "Every suggestion, with a prompt for your agent", "available": true }, { "text": "GitHub App on private repositories", "available": true }, { "text": "Visitor analytics", "available": true }, { "text": "Alerts to Sentry, PostHog, Datadog, Slack or a webhook", "available": true }, @@ -56,7 +60,8 @@ { "text": "90 days of history", "available": true }, { "text": "Your whole team on the project", "available": true } ], - "per_project": true + "per_project": true, + "trial_days": 3 }, { "id": "enterprise", @@ -69,9 +74,9 @@ "per_seat": false, "limits": { "apps": null, "history_days": 365, "seats": null, "live_fixes_per_month": null }, "suggestions": { "per_day": 100, "shown": null }, - "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true }, + "entitlements": { "dashboard": true, "forwarding": true, "export": true, "analytics": true, "custom_model": true, "github_private": true, "problems": true, "ask": true }, "features": [ - { "text": "Everything in Team", "available": true }, + { "text": "Everything in Pro", "available": true }, { "text": "Your own classifier, trained by us", "available": true }, { "text": "Our models in our cloud", "available": true }, { "text": "The coding-agent loop run for your team", "available": true }, diff --git a/package-lock.json b/package-lock.json index 18d97eb0..33ecfb7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16819,7 +16819,7 @@ }, "packages/cli": { "name": "genclass", - "version": "0.3.5", + "version": "0.3.6", "license": "Apache-2.0", "bin": { "genclass": "bin/genclass.mjs" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 2ac42424..9efaf9df 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.3.6 + +The paid plan is Pro (it was called Team), with 3 days free: + +- **Free shows the totals and one suggestion.** `problems`, `events`, `report` and `fix` say what Pro adds, its + trial and price, and `genclass upgrade`; `dashboard` shows the headline numbers with the same box; `ask` is Pro's. +- **`genclass suggest` on Free: the first suggestion and how many more**, counting the checks, the model's own ideas + and website designs. A Free app is shown that same plan again on later runs (with its date) until it has Pro. +- **On Pro, suggest also prints the model's ideas** (marked as not measured) **and website designs**: each one is + inspired by a real site that fits what the company does, with a complete prompt to paste into Claude. The hand-off + menu copies a design's prompt in one key. +- The digest sent for a plan now includes what the site says it is about (its title and description), shown before + it is sent. +- The plan card and the Free line name the trial: 3 days free, then $15 a project a month. +- Suggest's lines fit 58 columns. + ## 0.3.5 For onboarding paying customers: diff --git a/packages/cli/README.md b/packages/cli/README.md index cc90b754..1970d31c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,7 @@ Run it in your web app's folder. It shows where the project stands and walks you per step. Stop at any point and run it again: it continues from what is true now. ```text - ╭─ GenClass ────────────────────────────────────────────── v0.3.5 ─╮ + ╭─ GenClass ────────────────────────────────────────────── v0.3.6 ─╮ │ │ │ Welcome back, Ada! │ │ │ diff --git a/packages/cli/package.json b/packages/cli/package.json index 401f7047..aabc05f3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "genclass", - "version": "0.3.5", + "version": "0.3.6", "description": "GenClass CLI: one command that sets GenClass up, shows your app in the terminal (problems, analytics, events), hands problems to your coding agent, answers questions about your app and tells you what it is missing.", "type": "module", "license": "Apache-2.0", diff --git a/packages/cli/src/api.mjs b/packages/cli/src/api.mjs index 1c9c4bbd..424ea56e 100644 --- a/packages/cli/src/api.mjs +++ b/packages/cli/src/api.mjs @@ -79,6 +79,7 @@ function offerOf(o, host) { features: (Array.isArray(o.features) ? o.features : []).filter((x) => typeof x === "string" && x.trim()).slice(0, 12).map((x) => x.slice(0, 120)), checkoutUrl, free: { name: str(f.name, 40) ?? "Free", apps: num(f.apps), historyDays: num(f.history_days), suggestionsShown: num(f.suggestions_shown), liveFixes: num(f.live_fixes) }, + trialDays: num(o.trial_days), }; } @@ -114,7 +115,8 @@ export async function fetchRepoStatus(creds, repo, opts = {}) { * @returns {Promise<{ ok: true, source: "model" | "checks", plan: { id: string, name: string } | null, total: number, items: { id: string, why: string | null, step: string | null, prompt: string | null }[], locked: number, lockedIds: string[], upgradeUrl: string | null, remainingToday: number | null, limited: boolean } | { ok: false, status: number, reason: string, missing?: true }>} */ export async function requestPlan(creds, digest, opts = {}) { - const r = await request("/api/cli/suggest", { method: "POST", token: creds.access_token, host: creds.host, body: digest, fetchFn: opts.fetchFn, timeoutMs: opts.timeoutMs ?? 45_000 }); + // the model works through the checks, its own ideas and three website designs: up to 90 seconds on the server + const r = await request("/api/cli/suggest", { method: "POST", token: creds.access_token, host: creds.host, body: digest, fetchFn: opts.fetchFn, timeoutMs: opts.timeoutMs ?? 120_000 }); if (!r.ok) return { ok: false, status: r.status, reason: failure(r), ...(r.status === 404 ? { missing: true } : {}) }; const j = r.json ?? {}; const items = (Array.isArray(j.items) ? j.items : []) @@ -132,6 +134,20 @@ export async function requestPlan(creds, digest, opts = {}) { upgradeUrl: sameOrigin(j.upgrade_url, creds.host), remainingToday: Number.isFinite(Number(j.remaining_today)) && j.remaining_today !== null ? Number(j.remaining_today) : null, limited: j.limited === true, + /** the model's own ideas (not measured) and website designs: Pro gets them, Free their counts */ + ideas: (Array.isArray(j.ideas) ? j.ideas : []) + .filter((i) => i && typeof i === "object" && str(i.title, 120)) + .slice(0, 20) + .map((i) => ({ title: str(i.title, 120), why: str(i.why, 600), step: str(i.step, 400), prompt: str(i.prompt, 1200) })), + designs: (Array.isArray(j.designs) ? j.designs : []) + .filter((d) => d && typeof d === "object" && str(d.title, 120) && /^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/\S*)?$/i.test(String(d.inspired_by ?? "")) && str(d.prompt, 4000)) + .slice(0, 4) + .map((d) => ({ title: str(d.title, 120), inspiredBy: String(d.inspired_by).slice(0, 200), why: str(d.why, 600), prompt: str(d.prompt, 4000) })), + ideasLocked: Number.isFinite(Number(j.ideas_locked)) ? Math.max(0, Number(j.ideas_locked)) : 0, + designsLocked: Number.isFinite(Number(j.designs_locked)) ? Math.max(0, Number(j.designs_locked)) : 0, + /** this answer was kept from an earlier run (Free sees its first one again until it pays) */ + cachedAt: str(j.cached_at, 40), + upgradePlan: j.upgrade_plan && typeof j.upgrade_plan === "object" ? { plan: str(j.upgrade_plan.name, 40), price: str(j.upgrade_plan.price, 60), trialDays: Number.isInteger(j.upgrade_plan.trial_days) && j.upgrade_plan.trial_days > 0 ? j.upgrade_plan.trial_days : null } : null, }; } @@ -157,6 +173,13 @@ export async function signInIsOld(creds, opts = {}) { return old(await ask("/api/cli/app?id=-&range=24h")); } +/** A part the plan leaves out, as the server said it: { plan, trialDays, price, upgradeUrl }, or null. */ +function lockOf(l, host) { + if (!l || typeof l !== "object") return null; + const days = Number(l.trial_days); + return { plan: str(l.plan, 40), trialDays: Number.isInteger(days) && days > 0 ? days : null, price: str(l.price, 60), upgradeUrl: sameOrigin(l.upgrade_url, host) }; +} + /** The live-fix count the server sent with an app's dashboard (src/usage.ts there), or null. */ function liveUsage(u) { if (!u || typeof u !== "object" || !Number.isFinite(Number(u.used))) return null; @@ -203,6 +226,8 @@ export async function fetchApp(creds, id, range = "7d", opts = {}) { visitorsPlan: lock ? { plan: str(lock.plan, 40), upgradeUrl: sameOrigin(lock.upgrade_url, creds.host) } : null, agent: { found: count(st.found), reproduced: count(st.reproduced), fixed: count(st.fixed), closed: count(st.closed), open: count(st.open), last: str(j.agent?.last, 40) }, dashboardUrl: sameOrigin(j.dashboard_url, creds.host), + /** Free sees the totals only: the plan that shows the problems, its trial and price, and its page */ + problemsPlan: lockOf(j.locked?.problems, creds.host), /** the problems fixed live this month against the app's plan: past its limit GenClass observes only */ usage: liveUsage(j.usage), /** the app's plan page (plans per project) */ @@ -282,6 +307,8 @@ const refused = (r, host, what) => ({ ...(r.status === 404 && (typeof r.json?.error !== "string" || r.json.error === "not found") ? { missing: true } : {}), ...(r.json?.reauth === true ? { reauth: true } : {}), ...(sameOrigin(r.json?.upgrade_url, host) ? { upgradeUrl: sameOrigin(r.json.upgrade_url, host) } : {}), + // a part of the paid plan: which plan, its trial and price (the Pro box) + ...(r.json?.locked === true ? { lock: lockOf(r.json, host) } : {}), }); const numOrNull = (v) => (Number.isFinite(Number(v)) && v !== null ? Number(v) : null); const person = (x) => ({ label: str(x?.email, 120) ?? (str(x?.github_login, 60) ? `@${str(x.github_login, 60)}` : "a GenClass account"), joined: str(x?.joined, 40), active: x?.active !== false }); diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index e7192322..f5cdb03a 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -16,7 +16,15 @@ import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; import { openBrowser, signIn } from "./actions.mjs"; import { fetchApp, signInIsOld } from "./api.mjs"; import { readState } from "./state.mjs"; -import { ago, bad, clean, header, note, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs"; +import { ago, bad, clean, header, note, proBox, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs"; + +/** The views a Free app does not have (the server sends their data to Pro only), and what the Pro box says about each. */ +const PRO_VIEWS = { + problems: "Every problem, the pages it happens on and what GenClass did come with Pro.", + events: "The latest events, one per line, come with Pro.", + report: "The period as Markdown comes with Pro.", + fix: "Handing problems to your coding agent, with a fix prompt for each, comes with Pro.", +}; /** "Dec 1" for an ISO time (UTC). */ const monthDay = (iso) => { @@ -124,7 +132,9 @@ export function render(app, state, o = {}) { out(); out(` ${c.green(sym.ok)} No problems found in this period.`); } - table("Problems", rows(app.problems), all ? 200 : 5, KINDS, all); + // Free: the totals above, and the way to the rest + if (app.problemsPlan) proBox("The problems, the pages they happen on and a fix prompt for each come with Pro. Free shows the totals.", app.problemsPlan); + else table("Problems", rows(app.problems), all ? 200 : 5, KINDS, all); if (all) table("Possible problems (lower confidence)", rows(app.possible), 200, KINDS, all); table("Visual checks", rows(app.checks), all ? 200 : 3, null, all); if (all && app.recent.length) { @@ -145,7 +155,7 @@ export function render(app, state, o = {}) { const bounce = v.visits ? ` · ${Math.round((v.bounces / v.visits) * 100)}% left after one page` : ""; part("Visitors", v.visits ? `${plural(v.visits, "visit")} · ${plural(v.pages, "page view")}${bounce}` : c.gray("none counted in this period (analytics is opt-in: genclass.dev/docs)")); if (v.visits && v.entry.length) for (const l of wrap(`came in on ${v.entry.slice(0, all ? 10 : 3).map((e) => `${clean(e.key, 30)} (${n(e.count)})`).join(", ")}`, width() - 4 - 15)) out(` ${" ".repeat(15)}${c.gray(l)}`); - } else if (app.visitorsPlan) part("Visitors", c.gray(`visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Team", 20)} plan${app.visitorsPlan.upgradeUrl ? `: ${app.visitorsPlan.upgradeUrl}` : ""}`)); + } else if (app.visitorsPlan) part("Visitors", c.gray(`visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Pro", 20)} plan${app.visitorsPlan.upgradeUrl ? `: ${app.visitorsPlan.upgradeUrl}` : ""}`)); if (u && u.limit !== null) part("Live fixes", u.observeOnly ? c.yellow(`${n(u.used)} of ${n(u.limit)} this month · observing only`) : `${n(u.used)} of ${n(u.limit)} this month`); else if (u?.used) part("Live fixes", `${n(u.used)} this month · no limit`); const a = app.agent; @@ -294,9 +304,20 @@ export async function appView(o = {}, deps = {}, view) { return 1; } if (o.json) { + if (app.problemsPlan && PRO_VIEWS[view.name]) { + out(JSON.stringify({ ok: false, locked: true, error: `${PRO_VIEWS[view.name]}`, plan: app.problemsPlan.plan, trial_days: app.problemsPlan.trialDays, upgrade_url: app.problemsPlan.upgradeUrl, run: "npx genclass upgrade" }, null, 2)); + return 0; + } out(JSON.stringify((view.json ?? ((a, st, t) => ({ ok: true, app: t.name, ...a })))(app, state, target), null, 2)); return 0; } + // Free sees the totals only (the server left the rest out): these views are Pro's, and say so with the way to it + if (app.problemsPlan && PRO_VIEWS[view.name]) { + header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); + proBox(PRO_VIEWS[view.name], app.problemsPlan); + out(); + return 0; + } // a view whose output is meant to be piped or pasted (the report) has no card over it if (!view.plain) header({ app: target.name, framework: state.app.token === target.token ? state.app.framework : null, account: state.account?.label, plan: state.account?.plan?.name, host: state.host }); const code = await view.show(app, state, target, readCredentialsFor().creds ?? creds); diff --git a/packages/cli/src/ask.mjs b/packages/cli/src/ask.mjs index ed78f863..cb9d12c6 100644 --- a/packages/cli/src/ask.mjs +++ b/packages/cli/src/ask.mjs @@ -108,7 +108,7 @@ export async function ask(o = {}, deps = {}) { if (r.answer === null) { if (r.why === "limit") { warn(`Today's ${r.perDay === null ? "" : `${n(r.perDay)} `}model runs are used (ask and suggest share them). They come back tomorrow.`); - if (r.upgradeUrl) out(` ${c.gray("More a day with the Team plan:")} ${c.cyan("npx genclass plan")}`); + if (r.upgradeUrl) out(` ${c.gray("More a day with Pro:")} ${c.cyan("npx genclass upgrade")}`); } else if (r.why === "no-model") note("This genclass.dev has no model to ask, so there is no answer in words."); else warn("The model did not answer this time. Asking again in a moment usually works."); out(` ${c.gray("The numbers themselves:")} ${c.cyan("npx genclass problems")}`); diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index 26efe65d..c1fac798 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -237,7 +237,7 @@ export async function freeLine(o = {}, deps = {}) { const offer = upgradeOffer(state); if (!offer) return false; const lim = freeWords(offer.free); - const [first = "", ...rest] = wrap(`You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${clean(offer.plan.name, 20)} has none, for ${clean(offer.price, 40)}. Upgrade now: npx genclass upgrade`, width() - 6); + const [first = "", ...rest] = wrap(`You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${clean(offer.plan.name, 20)} has none, ${offer.trialDays ? `${offer.trialDays} days free, then ${clean(offer.price, 40)}` : `for ${clean(offer.price, 40)}`}. Upgrade now: npx genclass upgrade`, width() - 6); out(); out(` ${c.yellow(sym.warn)} ${c.bold(first)}`); for (const l of rest) out(` ${l}`); @@ -373,7 +373,7 @@ export async function team(o = {}, deps = {}) { out(); if (t.seats !== null && t.seats <= 1) { out(` ${c.bold(`${clean(a.plan.name, 20)} is for one person.`)}`); - note("A team comes with Team: everyone you invite opens the same dashboards."); + note("A team comes with Pro: everyone you invite opens the same dashboards."); out(` ${c.cyan("npx genclass plan")}`); } else { out(` ${c.gray("Invite someone:")} ${c.cyan("npx genclass team invite")}`); @@ -518,7 +518,7 @@ export async function connections(o = {}, deps = {}) { } if (!r.forwarding) { out(); - out(` ${c.bold(`Alerts come with the ${clean(r.plan ?? "Team", 20)} plan.`)} ${c.cyan("npx genclass plan")}`); + out(` ${c.bold(`Alerts come with the ${clean(r.plan ?? "Pro", 20)} plan.`)} ${c.cyan("npx genclass plan")}`); } else { out(); out(` ${c.gray("Set one up:")} ${c.cyan("npx genclass connections add slack")}`); @@ -577,7 +577,7 @@ export async function exportEvents(o = {}, deps = {}) { if (!r.ok) { sp?.stop(null); if (toStd && !o.json) { - process.stderr.write(`genclass export: ${r.reauth ? "sign in again: npx genclass login" : r.upgradeUrl ? "the stored events as a file come with the Team plan: npx genclass plan" : (r.reason ?? "it did not work")}\n`); + process.stderr.write(`genclass export: ${r.reauth ? "sign in again: npx genclass login" : r.upgradeUrl ? "the stored events as a file come with Pro: npx genclass plan" : (r.reason ?? "it did not work")}\n`); return 1; } return refused(r, { what: "The stored events as a file", command: ["export", o.from ? `--from ${o.from}` : "", o.to ? `--to ${o.to}` : "", o.format ? `--format ${o.format}` : "", o.out ? `--out ${o.out}` : ""].filter(Boolean).join(" "), browserUrl: target.dashboard_url, json: o.json }); diff --git a/packages/cli/src/session.mjs b/packages/cli/src/session.mjs index f28b05da..78bb5b8c 100644 --- a/packages/cli/src/session.mjs +++ b/packages/cli/src/session.mjs @@ -11,7 +11,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 { bad, clean, note, typed, warn } from "./tui.mjs"; +import { bad, clean, note, proBox, typed, warn } from "./tui.mjs"; /** * Runs `call(creds)` (one of api.mjs's calls). When the answer says this sign-in is from before the command could @@ -47,10 +47,8 @@ export function refused(r, ctx) { out(); if (r.reauth) warn(`This sign-in is from before the command could do this. Run ${c.cyan("npx genclass login")} once, then ${c.cyan(`npx genclass ${ctx.command}`)}.`); else if (r.upgradeUrl) { - out(` ${c.bold(`${ctx.what} comes with the ${clean(ctx.plan ?? "Team", 20)} plan.`)}`); - note("Everything GenClass finds stays free. The plan adds this, and it starts from here:"); - out(` ${c.cyan("npx genclass plan")}`); - out(` ${c.gray("In the browser")} ${c.cyan(r.upgradeUrl)}`); + const lock = r.lock ?? { plan: ctx.plan ?? null, upgradeUrl: r.upgradeUrl }; + proBox(`${ctx.what} comes with ${clean(lock.plan ?? ctx.plan ?? "Pro", 20)}.`, { ...lock, upgradeUrl: lock.upgradeUrl ?? r.upgradeUrl }); } else if (r.missing) note("This genclass.dev cannot do this from the terminal yet."); else bad(`${ctx.what}: ${clean(r.reason ?? "it did not work", 240)}`); if (ctx.browserUrl && !r.upgradeUrl) out(` ${c.gray("In the browser")} ${c.cyan(ctx.browserUrl)}`); diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index 1daca57d..02ecd09d 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -330,7 +330,7 @@ export async function readState(o = {}, deps = {}) { else if (!repo || !repo.ok) Object.assign(gh, repo?.missing ? { state: "waiting", detail: "check it at genclass.dev/account/github", run: null } : { detail: `could not check${repo ? ` (${repo.reason})` : ""}` }); else if (!repo.known) Object.assign(gh, { detail: "continue with GitHub to check", human: "install the GitHub App and continue with GitHub in your browser" }); else if (!repo.installed) Object.assign(gh, { detail: `not on ${git.github} yet`, human: "install the GitHub App on this repository" }); - else if (repo.needsPlan) Object.assign(gh, { state: "waiting", detail: "private repository: reviews come with the Team plan", run: null }); + else if (repo.needsPlan) Object.assign(gh, { state: "waiting", detail: "private repository: reviews come with Pro", run: null }); else if (repo.paused) Object.assign(gh, { state: "waiting", detail: "paused on your GitHub page at genclass.dev", run: null }); else if (repo.evidence) Object.assign(gh, { state: "done", detail: repo.prs ? `reviewing · ${repo.prs} pull request${repo.prs === 1 ? "" : "s"} so far` : "reviewing" }); else Object.assign(gh, { state: "waiting", detail: "installed · open a pull request and the first review appears", run: null, human: workflows.length ? "commit the workflow file and open a pull request" : "open a pull request" }); diff --git a/packages/cli/src/suggest/digest.mjs b/packages/cli/src/suggest/digest.mjs index 51aa9cfd..c66c1153 100644 --- a/packages/cli/src/suggest/digest.mjs +++ b/packages/cli/src/suggest/digest.mjs @@ -27,6 +27,8 @@ export function buildDigest(app, results) { site: app.site ? clean(app.site, 200) : null, payments: app.payments ? clean(app.payments, 30) : null, hosting: app.hosting ? clean(app.hosting, 30) : null, + // what the site says it is about (its title and meta description): the design ideas fit the company with it + about: app.about ? clean(app.about, 300) : null, }, checks: failed.map((r) => ({ id: r.id, area: r.area, title: clean(r.title, 100), weight: r.weight, evidence: (r.evidence ?? []).slice(0, 3).map((e) => clean(e, 160)).filter(Boolean) })), passed: results.filter((r) => r.status === "pass").length, @@ -36,10 +38,10 @@ export function buildDigest(app, results) { /** The lines that say, in plain words, what a digest holds (shown before it is sent). */ export function describeDigest(d) { - const about = [d.app.name && "its name", d.app.framework && "its framework", d.app.site && "its address", d.app.payments && "its payment provider", d.app.hosting && "its host"].filter(Boolean); + const about = [d.app.name && "its name", d.app.framework && "its framework", d.app.site && "its address", d.app.about && "its site's title and description", d.app.payments && "its payment provider", d.app.hosting && "its host"].filter(Boolean); return [ `The ${d.checks.length} check${d.checks.length === 1 ? "" : "s"} that failed, each with its title and the evidence lines printed below.`, about.length ? `About the app: ${about.join(", ")}.` : null, - "No source code, no file contents and no secrets. genclass.dev passes this to the model that writes the plan and keeps none of it.", + "No source code, no file contents and no secrets. genclass.dev passes this to the model that writes the plan, and keeps the plan it writes for this app, not what was sent.", ].filter(Boolean); } diff --git a/packages/cli/src/suggest/index.mjs b/packages/cli/src/suggest/index.mjs index 1fd51efa..e6ac9295 100644 --- a/packages/cli/src/suggest/index.mjs +++ b/packages/cli/src/suggest/index.mjs @@ -18,14 +18,14 @@ import { c, interactive, out, sym } from "../../../runtime/bin/lib/ui.mjs"; import { requestPlan } from "../api.mjs"; import { copyText, findAgents, startAgent } from "../fix.mjs"; import { readNotes, saveNotes } from "../store.mjs"; -import { agree, clean, good, header, note, para, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs"; +import { agree, clean, good, header, note, para, proBox, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs"; import { AREAS, runChecks } from "./checks.mjs"; import { buildDigest, describeDigest } from "./digest.mjs"; import { collectRepoFacts } from "./repo.mjs"; import { collectSiteFacts } from "./site.mjs"; /** Items shown in full when no server said how many (not signed in, or a server without the route). */ -export const FREE_SHOWN = 3; +export const FREE_SHOWN = 1; /** A site address as typed: with https:// in front of a bare host, or null when it is not one. */ export function siteAddress(v) { @@ -70,11 +70,14 @@ const dots = (weight) => (weight >= 5 ? c.red("urgent") : weight >= 4 ? c.yellow /** Prints one item of the plan. */ function printItem(i, full) { out(); - const title = ` ${c.bold(String(i.n).padStart(2))} ${c.bold(clean(i.title, 90))}`; const tag = `${c.gray(`${AREAS[i.area] ?? i.area} ${sym.dot}`)} ${dots(i.weight)}`; - if (visible(title) + 2 + visible(tag) <= width()) out(`${title} ${tag}`); + // the title on as many lines as it takes at this width, the tag after it or under it + const [first = "", ...more] = wrap(clean(i.title, 90), width() - 8); + const title = ` ${c.bold(String(i.n).padStart(2))} ${c.bold(first)}`; + if (!more.length && visible(title) + 2 + visible(tag) <= width() - 2) out(`${title} ${tag}`); else { out(title); + for (const l of more) out(` ${c.bold(l)}`); out(` ${tag}`); } for (const e of i.evidence ?? []) { @@ -94,6 +97,48 @@ function printItem(i, full) { * @param {{ fetchFn?: typeof fetch, dns?: object, runGit?: Function }} [deps] * @returns {Promise} the exit code: 0, or 1 when the site was named and could not be reached at all */ +/** "Nov 12" for an ISO time. */ +const day = (iso) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? String(iso).slice(0, 10) : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +/** The model's own ideas (Pro): not measured, and said so. */ +function printIdeas(ideas, all) { + out(); + rule(); + out(` ${c.bold("Ideas from the model")}`); + note("Not measured: judged from your app and its site."); + ideas.forEach((d, i) => { + out(); + out(` ${c.bold(String(i + 1).padStart(2))} ${c.bold(clean(d.title, 90))}`); + if (!all && i >= 5) return; + if (d.why) para(clean(d.why, 600), " "); + if (d.step) out(` ${c.cyan("Do")} ${wrap(clean(d.step, 400), width() - 12).join("\n ")}`); + if (d.prompt) { + out(` ${c.cyan("Ask your agent")}`); + para(clean(d.prompt, 1000), " ", c.gray); + } + }); + if (!all && ideas.length > 6) note(`Ideas 7 and after are listed by title: ${c.cyan("npx genclass suggest --all")} prints each one in full.`); +} + +/** The website designs (Pro): each inspired by a real site, with the whole prompt to paste into a coding agent. */ +function printDesigns(designs) { + out(); + rule(); + out(` ${c.bold("Website designs")}`); + note("The model's choice of sites that fit what you do."); + designs.forEach((d, i) => { + out(); + out(` ${c.bold(String(i + 1).padStart(2))} ${c.bold(clean(d.title, 90))}`); + out(` ${c.gray("Inspired by")} ${c.cyan(clean(d.inspiredBy, 200))}`); + if (d.why) para(clean(d.why, 600), " "); + out(` ${c.cyan("Prompt for Claude")} ${c.gray("(copy it all)")}`); + para(clean(d.prompt, 4000), " ", c.gray); + }); +} + /** * The whole list as one prompt for a coding agent in the repository: each item with its reason, first step and * prompt (the ones the plan shows), the rest by title, most important first, and how to check the work. @@ -138,6 +183,8 @@ async function handToAgent(items, url, cwd, o, deps, plan) { }]), ["Copy the prompt", async () => ((await (deps.copy ?? copyText)(prompt)) ? good("Copied. Paste it into your coding agent.") : out(prompt))], ["Print the prompt", async () => out(`\n${prompt}\n`)], + // each website design's own prompt, to paste into Claude whole + ...(plan?.designs ?? []).map((d) => [`Copy the design prompt: ${clean(d.title, 40)}`, async () => ((await (deps.copy ?? copyText)(d.prompt)) ? good("Copied. Paste it into Claude in your repository.") : out(d.prompt))]), ]; 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)."); @@ -179,7 +226,8 @@ export async function suggest(o = {}, deps = {}) { const failed = results.filter((r) => r.status === "fail"); const passed = results.filter((r) => r.status === "pass"); const unknown = results.filter((r) => r.status === "unknown"); - const app = { name: repo.name ?? null, framework: st.project.label || null, site: site?.reachable ? url : null, payments: repo.payments?.provider ?? null, hosting: repo.hosting?.provider ?? null }; + const about = site?.reachable ? [site.defaultTitle ? null : site.title, site.description].filter(Boolean).join(". ") || null : null; + const app = { name: repo.name ?? null, framework: st.project.label || null, site: site?.reachable ? url : null, about, payments: repo.payments?.provider ?? null, hosting: repo.hosting?.provider ?? null }; const digest = buildDigest(app, results); // ---- the plan @@ -187,7 +235,7 @@ export async function suggest(o = {}, deps = {}) { let plan = null; let planNote = null; if (o.noModel) planNote = "Ordered by the checks' own weights (--no-model: nothing was sent)."; - else if (!failed.length) planNote = null; + else if (!failed.length && !app.site) planNote = null; else if (!creds) planNote = `Ordered by the checks' own weights. Sign in (${c.cyan("npx genclass login")}) and the order and the reasons are written for your app.`; else { let agreed = o.yes || notes.sendOk === true; @@ -200,11 +248,11 @@ export async function suggest(o = {}, deps = {}) { } if (!agreed) planNote = `Ordered by the checks' own weights: nothing was sent. ${quiet || !interactive() ? "Pass --yes to send the failed checks to genclass.dev for a plan written for your app." : ""}`.trim(); else { - const sp = quiet ? null : spinner("Writing the plan for your app"); + const sp = quiet ? null : spinner("Writing the plan for your app (up to a minute)"); const r = await requestPlan(creds, digest, { fetchFn: deps.fetchFn }); if (r.ok) { plan = { ...r, lockedIds: r.lockedIds ?? [] }; - sp?.stop("ok", r.source === "model" ? "The plan is written for your app." : r.limited ? "Today's plans are used up, so this one is ordered by the checks' own weights." : "Ordered by the checks' own weights."); + sp?.stop("ok", r.cachedAt ? `Your plan from ${day(r.cachedAt)}.` : r.source === "model" ? "The plan is written for your app." : r.limited ? "Today's plans are used up, so this one is ordered by the checks' own weights." : "Ordered by the checks' own weights."); } else { sp?.stop("warn", r.missing ? "This genclass.dev has no suggestions yet: ordered by the checks' own weights." : `No plan from genclass.dev (${clean(r.reason, 120)}): ordered by the checks' own weights.`); } @@ -222,9 +270,14 @@ export async function suggest(o = {}, deps = {}) { source: plan?.source ?? "checks", plan: plan?.plan ?? null, passed: passed.length, - failed: items.map((i) => ({ n: i.n, id: i.id, area: i.area, title: i.title, weight: i.weight, evidence: i.evidence, ...(i.open ? { why: i.why, do: i.step, prompt: i.prompt } : { locked: true }) })), + failed: items.filter((i) => i.open).map((i) => ({ n: i.n, id: i.id, area: i.area, title: i.title, weight: i.weight, evidence: i.evidence, why: i.why, do: i.step, prompt: i.prompt })), + // what the plan does not show is counted, not listed (Free: the first suggestion only) + more: { checks: locked.length, ideas: plan?.ideasLocked ?? 0, designs: plan?.designsLocked ?? 0 }, + ideas: plan?.ideas ?? [], + designs: (plan?.designs ?? []).map((d) => ({ title: d.title, inspired_by: d.inspiredBy, why: d.why, prompt: d.prompt })), + cached_at: plan?.cachedAt ?? null, not_checked: unknown.map((r) => r.id), - upgrade_url: locked.length ? (plan?.upgradeUrl ?? null) : null, + upgrade_url: locked.length || plan?.ideasLocked || plan?.designsLocked ? (plan?.upgradeUrl ?? null) : null, }, null, 2, @@ -239,17 +292,25 @@ export async function suggest(o = {}, deps = {}) { out(` ${c.bold(`${passed.length} of ${total} checks pass.`)} ${failed.length ? `${failed.length} to do${failed.some((r) => r.weight >= 5) ? `, ${failed.filter((r) => r.weight >= 5).length} of them urgent` : ""}.` : c.green("Nothing to do.")}`); if (planNote) note(planNote); if (!site?.reachable) note(url ? "The site could not be read, so only the repository was checked." : `Only the repository was checked. With the site's address there is more: ${c.cyan("npx genclass suggest https://your-site.com")}`); + // Free: the first suggestion only, and how many more there are (the rest is Pro's, the owner's call 2026-11-12) const fullCount = o.all ? items.length : 5; - for (const i of items) printItem(i, i.open && i.n <= fullCount); + for (const i of items.filter((x) => x.open)) printItem(i, i.n <= fullCount); if (items.some((i) => i.open && i.n > fullCount)) { out(); note(`Items ${fullCount + 1} and after are listed by title. ${c.cyan("npx genclass suggest --all")} prints each one's reason, first step and prompt.`); } - if (locked.length) { + if (plan?.ideas?.length) printIdeas(plan.ideas, o.all); + if (plan?.designs?.length) printDesigns(plan.designs); + const hiddenIdeas = plan?.ideasLocked ?? 0; + const hiddenDesigns = plan?.designsLocked ?? 0; + const more = locked.length + hiddenIdeas + hiddenDesigns; + if (more) { out(); rule(); - out(` ${c.bold(`The reason, the first step and the agent prompt for the other ${locked.length} come with the Team plan.`)}`); - note(`${plan?.plan?.name ?? "Free"} shows the plan for the first ${items.length - locked.length}. Every check above is measured for free. Plans: ${c.cyan(plan?.upgradeUrl ?? "https://genclass.dev/pricing")}`); + const parts = [locked.length && `${locked.length} from your checks`, hiddenIdeas && `${hiddenIdeas} idea${hiddenIdeas === 1 ? "" : "s"} from the model`, hiddenDesigns && `${hiddenDesigns} website design${hiddenDesigns === 1 ? "" : "s"} inspired by sites like yours`].filter(Boolean); + const up = plan?.upgradePlan ?? {}; + proBox(`${more} more suggestion${more === 1 ? "" : "s"} for this app: ${parts.join(", ")}, each with a prompt for your coding agent.`, { plan: up.plan ?? "Pro", trialDays: up.trialDays ?? null, price: up.price ?? null, upgradeUrl: plan?.upgradeUrl ?? null }); + if (plan?.cachedAt) note(`This is your plan from ${day(plan.cachedAt)}: on Free it stays this one. Pro writes it again every time you run this.`); } if (unknown.length) { out(); @@ -258,13 +319,13 @@ export async function suggest(o = {}, deps = {}) { else note(`${unknown.length} check${unknown.length === 1 ? "" : "s"} could not be measured on this site this time.`); } out(); - if (failed.length) { - good("Fix one, then run it again: everything is measured afresh."); + if (failed.length && !more) { + good("Fix one, then run this again: it measures afresh."); out(` ${c.cyan("npx genclass suggest")}`); note(`With a coding agent: paste an "Ask your agent" block, or tell it to run ${c.cyan("npx genclass suggest --json")} and work through the list.`); } // on a terminal: every item handed to a coding agent in one go (Claude Code, Codex, Cursor, Gemini CLI) - if (items.length && interactive() && !o.yes) await handToAgent(items, site?.reachable ? url : null, cwd, o, deps, plan); + if ((items.some((i) => i.open) || plan?.designs?.length) && interactive() && !o.yes) await handToAgent(items.filter((i) => i.open), site?.reachable ? url : null, cwd, o, deps, plan); out(); return url && site && !site.reachable && o.url ? 1 : 0; } diff --git a/packages/cli/src/tui.mjs b/packages/cli/src/tui.mjs index 66673708..432ce8b9 100644 --- a/packages/cli/src/tui.mjs +++ b/packages/cli/src/tui.mjs @@ -167,6 +167,20 @@ export function card(h = {}) { * @param {{ plan: { name: string }, price: string, summary?: string | null, features: string[] }} offer * @param {string | null} [app] the project it is chosen for */ +/** + * The Pro box: what comes with the paid plan, its trial and price, and the command that starts it. `lock` is what + * the server said ({ plan, trialDays, price, upgradeUrl }); every part of it is cleaned before it is printed. + */ +export function proBox(what, lock = {}) { + const plan = clean(lock.plan ?? "Pro", 20); + out(); + out(` ${badge(plan.toUpperCase())}`); + for (const l of wrap(clean(what, 300), width() - 6)) out(` ${c.bold(l)}`); + const terms = lock.trialDays ? `${lock.trialDays} days free, then ${clean(lock.price ?? "the monthly price", 40)}. Cancel any time.` : lock.price ? `${clean(lock.price, 40)}.` : ""; + for (const l of wrap(`${terms ? `${terms} ` : ""}Start: npx genclass upgrade`, width() - 6)) out(` ${l}`); + if (lock.upgradeUrl) out(` ${c.cyan(clean(lock.upgradeUrl, 200))}`); +} + export function planCard(offer, app) { const inner = width() - 6; const B = unicode ? { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" } : { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|" }; @@ -185,6 +199,8 @@ export function planCard(offer, app) { row(price); } if (offer.summary) for (const l of wrap(clean(offer.summary, 200), room)) row(c.gray(l)); + // the trial, when this account has one: said before anything else is + if (offer.trialDays) for (const l of wrap(`${offer.trialDays} days free, then ${clean(offer.price, 40)}. Cancel any time.`, room)) row(c.green(l)); row(); for (const f of offer.features) { const [first = "", ...rest] = wrap(clean(f, 120), room - 2); diff --git a/packages/cli/src/views.mjs b/packages/cli/src/views.mjs index 8b57e349..8407c022 100644 --- a/packages/cli/src/views.mjs +++ b/packages/cli/src/views.mjs @@ -114,7 +114,7 @@ export function renderAnalytics(app, o = {}) { period(app, o.now); if (app.visitorsPlan) { out(); - out(` ${c.bold(`Visitor analytics come with the ${clean(app.visitorsPlan.plan ?? "Team", 20)} plan.`)}`); + 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."); out(` ${c.cyan("npx genclass plan")}`); out(); diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index f35b9b4c..639127ce 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -53,6 +53,12 @@ async function account(env: Env, outside?: Model) { } const team3 = (sql: ReturnType["sql"], id: string) => setPlan(sql, id, "team", { stripe_mode: "live", seats: 3, subscription_id: "sub_t000000001", subscription_status: "active", current_period_end: "2026-12-13T12:00:00.000Z" }); const older = (sql: ReturnType["sql"]) => sql.db.exec(`UPDATE auth_sessions SET created = '${new Date(Date.parse(CLI_ACCOUNT_FROM) - 1000).toISOString()}' WHERE kind = 'cli'`); +/** The account's app on Pro, by GenClass's grant for that project (plans are per project). */ +async function pro(sql: ReturnType["sql"], me: Awaited>) { + sql.db.prepare("UPDATE projects SET comp_until = '2027-12-31T00:00:00.000Z', comp_plan = 'team' WHERE id = ?").run(me.app.id); + resetOwnerCache(); + return me; +} const flat = (text: string) => plainLines(text).join(" ").replace(/\s+/g, " "); /** What `fn` prints on a 58-column terminal, and its exit code. */ async function said(fn: () => Promise): Promise<{ text: string; code: number }> { @@ -95,7 +101,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("Model runs 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 - expect(f).toContain("T E A M"); + expect(f).toContain("P R O"); expect(f).toContain("$15 / project / month"); // plans are per project: the account's one app on Free has its own plan page expect(text).toContain(`${HOST}/account/billing?project=${me.app.id}&from=cli`); @@ -109,7 +115,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const line = await said(async () => ((await freeLine(me.o, me.deps)) ? 0 : 1)); expect(line.code).toBe(0); expect(tooWide(line.text)).toEqual([]); - expect(flat(line.text)).toContain("You are on Free, which has limits (3 apps, 100 live fixes a month, 7 days of history). Team has none, for $15 / project / month. Upgrade now: npx genclass upgrade"); + expect(flat(line.text)).toContain("You are on Free, which has limits (3 apps, 100 live fixes a month, 7 days of history). Pro has none, 3 days free, then $15 / project / month. Upgrade now: npx genclass upgrade"); // profile: who, the plan, each app's plan, and the choices (nobody at a terminal: as commands) const p = await said(() => profile(me.o, me.deps)); expect(p.code).toBe(0); @@ -119,7 +125,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(f).toContain("Signed in as ada@example.com"); expect(f).toContain("Plan Free (limited)"); expect(f).toContain("Apps and their plans Shop Free"); - expect(f).toContain("Upgrade Shop to Team $15 / project / month npx genclass upgrade"); + expect(f).toContain("Upgrade Shop to Pro $15 / project / month npx genclass upgrade"); 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?project=${me.app.id}&from=cli` }); // upgrade: the paid plan's card and the app's plan page @@ -139,19 +145,19 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { const me = await account(env); team3(sql, me.account.id); const f = flat((await said(() => plan(me.o, me.deps))).text); - expect(f).toContain("Plan Team $15 / project / month"); + expect(f).toContain("Plan Pro $15 / project / month"); expect(f).toContain("Renews 2026-12-13"); // Team is per project, everyone on it included expect(f).toContain("People 1 • no limit"); - expect(f).toContain("Apps and their plans Shop Team"); + expect(f).toContain("Apps and their plans Shop Pro"); expect(f).toContain("Apps 1 • no limit"); expect(f).toContain("Includes visitor analytics, alerts to Slack, Sentry and others, the stored events as a file, private repositories"); - expect(f).not.toContain("T E A M"); + expect(f).not.toContain("P R O"); // a sign-in the real server calls current is left alone by `genclass login` expect(await signInAgain({}, { ...me.deps, signIn: async () => "done" })).toBeNull(); older(sql); const old = flat((await said(() => plan(me.o, me.deps))).text); - expect(old).toContain("Plan Team"); + expect(old).toContain("Plan Pro"); expect(old).toContain("npx genclass login"); // and the command that line names does sign in again, instead of answering "already signed in" let signed = 0; @@ -168,7 +174,7 @@ describe("genclass plan, team and apps (src/manage.mjs)", () => { expect(free).toContain("npx genclass plan"); const no = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("A team comes with the Team plan."); + expect(flat(no.text)).toContain("A team comes with Pro."); team3(sql, me.account.id); const made = await said(() => team({ ...me.o, args: ["invite"] }, me.deps)); expect(made.code).toBe(0); @@ -231,13 +237,13 @@ describe("genclass connections (src/manage.mjs connections)", () => { expect(tooWide(text)).toEqual([]); const f = flat(text); for (const d of ["Slack", "Sentry", "PostHog", "Datadog", "Webhook"]) expect(f).toContain(`${d} not set up`); - expect(f).toContain("Alerts come with the Team plan. npx genclass plan"); + expect(f).toContain("Alerts come with the Pro plan. npx genclass plan"); expect(f).toContain("Private link off"); // setting one up on Free: the plan is why, said before anything is asked for, with the command that starts it const no = await typing([], () => said(() => connections({ ...me.o, args: ["add", "slack"] }, me.deps))); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("Sending alerts comes with the Team plan."); - expect(flat(no.text)).toContain("npx genclass plan"); + expect(flat(no.text)).toContain("Sending alerts comes with Pro."); + expect(flat(no.text)).toContain("npx genclass upgrade"); expect(no.text).not.toContain("Slack incoming webhook URL"); // a destination that was never set up cannot be turned on: the server's own sentence const never = await said(() => connections({ ...me.o, args: ["on", "slack"] }, me.deps)); @@ -291,8 +297,8 @@ describe("genclass export and funnels (src/manage.mjs)", () => { const me = await account(env); const no = await said(() => exportEvents(me.o, me.deps)); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("The stored events as a file comes with the Team plan."); - expect(flat(no.text)).toContain("npx genclass plan"); + expect(flat(no.text)).toContain("The stored events as a file comes with Pro."); + expect(flat(no.text)).toContain("npx genclass upgrade"); team3(sql, me.account.id); resetOwnerCache(); await sendBatch(env, me.app.token, [{ t: "session", host: "shop.example", route: "/" }, ...detection("stale", "/search")]); @@ -323,7 +329,7 @@ describe("genclass export and funnels (src/manage.mjs)", () => { const me = await account(env); const no = await said(() => funnels({ ...me.o, args: ["add", "Checkout", "/cart", "/checkout"] }, me.deps)); expect(no.code).toBe(1); - expect(flat(no.text)).toContain("Funnels (part of visitor analytics) comes with the Team plan."); + expect(flat(no.text)).toContain("Funnels (part of visitor analytics) comes with Pro."); team3(sql, me.account.id); expect(flat((await said(() => funnels(me.o, me.deps))).text)).toContain("No funnel yet."); const add = await said(() => funnels({ ...me.o, args: ["add", "Checkout", "/cart", "/checkout", "/thanks"] }, me.deps)); @@ -374,9 +380,10 @@ describe("genclass ask (src/ask.mjs)", () => { const model = (text: string, seen: any[] = []) => (async (_url: string, init?: RequestInit) => (seen.push(JSON.parse(String(init!.body))), new Response(JSON.stringify({ choices: [{ message: { content: text } }] }), { status: 200 }))) as Model; it("a question is answered from the app's numbers; a follow-up carries the turn before it; what is sent is said first", async () => { - const { env } = setup({ OPENAI_API_KEY: "sk-test-0123456789abcdef0123" }); + const { env, sql } = setup({ OPENAI_API_KEY: "sk-test-0123456789abcdef0123" }); const seen: any[] = []; - const me = await account(env, model("Two problems this week.\n\n- /checkout: the same request went out twice\n- /search: an older answer replaced a newer one, which is the kind of thing that happens when a slow response for an earlier query lands last", seen)); + const me = await pro(sql, await account(env, model("Two problems this week.\n\n- /checkout: the same request went out twice\n- /search: an older answer replaced a newer one, which is the kind of thing that happens when a slow response for an earlier query lands last", seen))); + sql.db.prepare("INSERT INTO suggest_usage (account_id, day, runs) VALUES (?, ?, 27)").run(me.account.id, now().toISOString().slice(0, 10)); await sendBatch(env, me.app.token, [{ t: "session", host: "shop.example", route: "/" }, ...detection("duplicate", "/checkout"), ...detection("stale", "/search")]); const errs: string[] = []; const spy = vi.spyOn(process.stderr, "write").mockImplementation(((s: unknown) => (errs.push(String(s)), true)) as never); @@ -405,16 +412,16 @@ describe("genclass ask (src/ask.mjs)", () => { // the day's runs are used: said, with the plan that has more and the numbers themselves const over = await said(() => ask({ ...me.o, question: "Once more?" }, me.deps)); expect(over.code).toBe(1); - expect(flat(over.text)).toContain("Today's 3 model runs are used (ask and suggest share them)."); - expect(flat(over.text)).toContain("npx genclass plan"); + expect(flat(over.text)).toContain("Today's 30 model runs are used (ask and suggest share them)."); + expect(flat(over.text)).toContain("npx genclass upgrade"); expect(flat(over.text)).toContain("npx genclass problems"); expect(seen).toHaveLength(3); }); it("on a terminal the first question waits for Enter after saying what is sent, and q sends nothing", async () => { - const { env } = setup({ OPENAI_API_KEY: "sk-test-0123456789abcdef0123" }); + const { env, sql } = setup({ OPENAI_API_KEY: "sk-test-0123456789abcdef0123" }); const seen: any[] = []; - const me = await account(env, model("Fine.", seen)); + const me = await pro(sql, await account(env, model("Fine.", seen))); const saved: Record[] = []; const deps = { ...me.deps, savePrefs: (p: Record) => (saved.push(p), true) }; const stopped = await typing(["q"], () => said(() => ask({ ...me.o, question: "How is it?" }, deps))); @@ -439,8 +446,15 @@ describe("genclass ask (src/ask.mjs)", () => { it("no model on the server, a sign-in from before, and no question each say the next step", async () => { const { env, sql } = setup(); - const me = await account(env); - const deps = { ...me.deps, readPrefs: () => ({ askTold: true }) }; + const free = await account(env); + const deps = { ...free.deps, readPrefs: () => ({ askTold: true }) }; + // on Free, asking is Pro's: the answer says which plan, its trial and price, and the command that starts it + expect(await askModel(free.creds, { id: free.app.id, question: "How is it?" }, free.deps)).toMatchObject({ ok: false, status: 403, lock: { plan: "Pro", trialDays: 3, price: "$15 / project / month" } }); + const locked = await said(() => ask({ ...free.o, question: "How is it?" }, deps)); + expect(locked.code).toBe(1); + expect(tooWide(locked.text)).toEqual([]); + expect(flat(locked.text)).toContain("A question to the model comes with Pro. 3 days free, then $15 / project / month. Cancel any time. Start: npx genclass upgrade"); + const me = await pro(sql, free); expect(await askModel(me.creds, { id: me.app.id, question: "How is it?" }, me.deps)).toMatchObject({ ok: true, answer: null, why: "no-model" }); const none = await said(() => ask({ ...me.o, question: "How is it?" }, deps)); expect(none.code).toBe(1); diff --git a/packages/cli/test/server.test.ts b/packages/cli/test/server.test.ts index 7c286557..fb175faa 100644 --- a/packages/cli/test/server.test.ts +++ b/packages/cli/test/server.test.ts @@ -44,7 +44,7 @@ describe("genclass against the collector (src/api.mjs, telemetry-worker/src/cli. ok: true, account: { id: me.account.id, email: "ada@example.com", github_login: null }, plan: { id: "free", name: "Free" }, - suggestions: { perDay: 3, shown: 3, usedToday: 0, model: false }, + suggestions: { perDay: 3, shown: 1, usedToday: 0, model: false }, github: { app: false, installUrl: null, connectUrl: null }, billingUrl: `${HOST}/account/billing`, }); @@ -82,17 +82,27 @@ describe("genclass against the collector (src/api.mjs, telemetry-worker/src/cli. await printed(async () => expect(await runInit("init", ["--cwd", dir, "--yes", "--no-install", "--no-network", "--token", app.token])).toBe(0)); expect((await sendBatch(env, app.token, [{ t: "session", host: "shop.example", route: "/" }, ...detection("duplicate", "/checkout"), ...detection("stale", "/orders/:id")])).status).toBe(202); const deps = { runGit: () => null, fetchFn: through(env), now: now().getTime() }; + // Free: the totals, and the way to the rest (the problems, their pages and the fix prompts are Pro's) const data = await fetchApp(me.creds, app.id, "7d", deps); - expect(data).toMatchObject({ ok: true, name: "shop", range: "7d", totals: { found: 2, checks: 0 }, visitors: null, visitorsPlan: { plan: "Team", upgradeUrl: `${HOST}/account/billing` }, dashboardUrl: `${HOST}/account/apps/${app.id}` }); - expect((data as any).problems.map((p: any) => p.key).sort()).toEqual(["duplicate\t/checkout", "stale\t/orders/:id"]); + expect(data).toMatchObject({ ok: true, name: "shop", range: "7d", totals: { found: 2, checks: 0 }, problems: [], visitors: null, visitorsPlan: { plan: "Pro", upgradeUrl: `${HOST}/account/billing` }, problemsPlan: { plan: "Pro", trialDays: 3, price: "$15 / project / month", upgradeUrl: `${HOST}/account/billing?project=${app.id}` }, dashboardUrl: `${HOST}/account/apps/${app.id}` }); let code = -1; + const freeView = await printed(async () => (code = await dashboard({ cwd: dir }, deps))); + expect(code).toBe(0); + expect(freeView).toMatch(/Problems found\s+2/); + expect(freeView.replace(/\s+/g, " ")).toContain("The problems, the pages they happen on and a fix prompt for each come with Pro. Free shows the totals."); + expect(freeView.replace(/\s+/g, " ")).toContain("3 days free, then $15 / project / month. Cancel any time. Start: npx genclass upgrade"); + expect(freeView).not.toContain("the same request went out twice"); + // on Pro (here GenClass's grant for this app): every problem + sql.db.prepare("UPDATE projects SET comp_until = '2027-12-31T00:00:00.000Z', comp_plan = 'team' WHERE id = ?").run(app.id); + const paid = await fetchApp(me.creds, app.id, "7d", deps); + expect((paid as any).problemsPlan).toBeNull(); + expect((paid as any).problems.map((p: any) => p.key).sort()).toEqual(["duplicate\t/checkout", "stale\t/orders/:id"]); const view = await printed(async () => (code = await dashboard({ cwd: dir }, deps))); expect(code).toBe(0); expect(view).toContain("last 7 days"); expect(view).toMatch(/Problems found\s+2/); expect(view).toMatch(/1\s+the same request went out twice\s+\/checkout/); expect(view).toMatch(/1\s+old data replaced newer data\s+\/orders\/:id/); - expect(view).toContain("visitor analytics come with the Team plan"); expect(view).toContain(`${HOST}/account/apps/${app.id}`); expect(view).not.toContain("SECRET PAGE TEXT"); // for a coding agent: the same as data @@ -113,30 +123,51 @@ describe("genclass against the collector (src/api.mjs, telemetry-worker/src/cli. expect(await fetchRepoStatus(me.creds, "acme/shop", { fetchFn: through(env) })).toEqual({ ok: true, known: false, installed: null, evidence: false, prs: 0, last: null, private: false, needsPlan: false, paused: false, connectUrl: null, installUrl: null }); expect(await fetchRepoStatus(me.creds, "not a repo", { fetchFn: through(env) })).toMatchObject({ ok: false, status: 400 }); - // the plan: the model's order, Free shown the first three and told which are held back - const model = async (_url: string, init?: RequestInit) => { + // the plan: the model's order, its own ideas and a website design; Free is shown the first item and the counts + let asked = 0; + const design = { title: "Calm product pages", inspired_by: "https://linear.app", why: "why linear", prompt: "Redesign the home page in a calm, precise style: ".repeat(4) }; + const model = async (url: string, init?: RequestInit) => { + // the design's site answers (the server drops a design whose site does not) + if (String(url).startsWith("https://linear.app")) return new Response("ok", { status: 200 }); + asked++; const failed = JSON.parse(JSON.parse(String(init!.body)).messages[1].content).failed as { id: string }[]; const items = [...failed].reverse().map((c) => ({ id: c.id, why: `why ${c.id}`, step: `step ${c.id}`, prompt: `prompt ${c.id}` })); - return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ items }) } }] }), { status: 200, headers: { "content-type": "application/json" } }); + const ideas = [1, 2].map((n) => ({ title: `idea ${n}`, why: `why idea ${n}`, step: `step idea ${n}`, prompt: `prompt idea ${n}` })); + return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ items, ideas, designs: [design] }) } }] }), { status: 200, headers: { "content-type": "application/json" } }); }; const free = await requestPlan(me.creds, DIGEST, { fetchFn: through(env, model) }); - expect(free).toEqual({ + const shut = { ok: true, source: "model", plan: { id: "free", name: "Free" }, total: 5, - items: ["llms-txt", "sitemap", "title"].map((id) => ({ id, why: `why ${id}`, step: `step ${id}`, prompt: `prompt ${id}` })), - locked: 2, - lockedIds: ["https", "canonical"], - upgradeUrl: `${HOST}/account/billing`, - remainingToday: 2, + items: [{ id: "llms-txt", why: "why llms-txt", step: "step llms-txt", prompt: "prompt llms-txt" }], + locked: 4, + lockedIds: ["sitemap", "title", "https", "canonical"], + // what Free does not see is counted, never sent + ideas: [], + designs: [], + ideasLocked: 2, + designsLocked: 1, + upgradeUrl: `${HOST}/account/billing?plan=team&from=cli`, + upgradePlan: { plan: "Pro", price: "$15 / project / month", trialDays: 3 }, limited: false, - }); - // with Team: all of it + }; + expect(free).toMatchObject({ ...shut, cachedAt: null, remainingToday: 2 }); + expect(asked).toBe(1); + // Free again: the same answer, kept from the first run, and no new model run + const again = await requestPlan(me.creds, DIGEST, { fetchFn: through(env, model) }); + expect(again).toMatchObject({ ...shut, remainingToday: 2 }); + expect((again as any).cachedAt).toMatch(/^20\d\d-/); + expect(asked).toBe(1); + // with Pro: all of it, a new run, the ideas and the design with the site it is inspired by setPlan(sql, me.account.id, "free", { comp_until: new Date(now().getTime() + 86_400_000).toISOString(), comp_plan: "team" }); const team = await requestPlan(me.creds, DIGEST, { fetchFn: through(env, model) }); - expect(team).toMatchObject({ ok: true, source: "model", plan: { id: "team", name: "Team" }, total: 5, locked: 0, lockedIds: [], upgradeUrl: null }); + expect(team).toMatchObject({ ok: true, source: "model", plan: { id: "team", name: "Pro" }, total: 5, locked: 0, lockedIds: [], upgradeUrl: null, ideasLocked: 0, designsLocked: 0, cachedAt: null }); expect((team as any).items).toHaveLength(5); + expect((team as any).ideas.map((i: any) => i.title)).toEqual(["idea 1", "idea 2"]); + expect((team as any).designs).toEqual([{ title: "Calm product pages", inspiredBy: "https://linear.app", why: "why linear", prompt: design.prompt.trim() }]); + expect(asked).toBe(2); // a server without the route (an older collector) says so, and the command carries on without a plan const none = (async () => new Response(JSON.stringify({ ok: false, error: "not found" }), { status: 404, headers: { "content-type": "application/json" } })) as unknown as typeof fetch; expect(await requestPlan(me.creds, DIGEST, { fetchFn: none })).toMatchObject({ ok: false, status: 404, missing: true }); diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index 925bdceb..f603f365 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -121,7 +121,7 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { expect(pr(await read(status({ evidence: true, prs: 4 })))).toMatchObject({ state: "done", detail: "reviewing · 4 pull requests so far" }); expect(pr(await read(status({ evidence: true, prs: 1 })))).toMatchObject({ detail: "reviewing · 1 pull request so far" }); // a private repository without a plan, and a paused one, wait and say why - expect(pr(await read(status({ private: true, needs_plan: true })))).toMatchObject({ state: "waiting", detail: "private repository: reviews come with the Team plan" }); + expect(pr(await read(status({ private: true, needs_plan: true })))).toMatchObject({ state: "waiting", detail: "private repository: reviews come with Pro" }); expect(pr(await read(status({ paused: true })))).toMatchObject({ state: "waiting" }); // a genclass.dev with no GitHub App (a collector someone hosts themselves): the step does not apply const noApp = await readState({ cwd: dir }, { runGit: git, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ projects: [project({ last_event: "2026-11-12T09:00:00.000Z" })], github: { app: false, install_url: null, connect_url: null } }) } }).fetchFn }); diff --git a/packages/cli/test/suggest.test.ts b/packages/cli/test/suggest.test.ts index ec82f99c..56298e71 100644 --- a/packages/cli/test/suggest.test.ts +++ b/packages/cli/test/suggest.test.ts @@ -10,6 +10,7 @@ import { CHECKS, runChecks } from "../src/suggest/checks.mjs"; import { buildDigest, describeDigest } from "../src/suggest/digest.mjs"; import { FREE_SHOWN, layOut, siteAddress, suggest, suggestionsPrompt } from "../src/suggest/index.mjs"; import { HOST, isolate, printed, tempDir, viteApp, write } from "./helpers.js"; +import { tooWide } from "./stats.js"; isolate(); beforeEach(() => { @@ -74,24 +75,26 @@ describe("genclass suggest: addresses, the digest and the layout (src/suggest/in { id: "staging", area: "launch", title: "?", weight: 3, status: "unknown", evidence: [] }, { id: "email-spf", area: "email", title: "n/a", weight: 3, status: "skip", evidence: [] }, ]; - const d = buildDigest({ name: "shop", framework: "Vite + React", site: "https://shop.example", payments: "stripe", hosting: null }, results as never); + const d = buildDigest({ name: "shop", framework: "Vite + React", site: "https://shop.example", payments: "stripe", hosting: null, about: `Polar Parts. Sled parts${ESC}[2J shipped across Canada. ${"y".repeat(400)}` }, results as never); expect(d).toEqual({ - app: { name: "shop", framework: "Vite + React", site: "https://shop.example", payments: "stripe", hosting: null }, + // what the site says it is about (its title and description), cut and cleaned: the design ideas fit the company with it + app: { name: "shop", framework: "Vite + React", site: "https://shop.example", payments: "stripe", hosting: null, about: expect.stringMatching(/^Polar Parts\. Sled parts shipped across Canada\. y+…$/) }, checks: [{ id: "title", area: "launch", title: "A title with an escape", weight: 4, evidence: ['the title is "Vite + React"', `${"x".repeat(159)}…`, "third"] }], passed: 1, unknown: 1, }); - expect(describeDigest(d).join(" ")).toMatch(/The 1 check that failed.*its name, its framework, its address, its payment provider\..*No source code/); + expect(d.app.about!.length).toBe(300); + expect(describeDigest(d).join(" ")).toMatch(/The 1 check that failed.*its name, its framework, its address, its site's title and description, its payment provider\..*No source code/); // every key the server reads, and no more expect(Object.keys(d.checks[0]!).sort()).toEqual(["area", "evidence", "id", "title", "weight"]); }); - it("without a server the order is by weight and the first three are open; with one, its order and its words", () => { + it("without a server the order is by weight and the first one is open; with one, its order and its words", () => { const r = (id: string, weight: number) => ({ id, area: "search", title: id, weight, status: "fail", evidence: [], why: `why ${id}`, fix: `fix ${id}`, prompt: `prompt ${id}` }); const results = [r("a", 1), r("b", 5), r("c", 3), r("d", 4), { ...r("e", 5), status: "pass" }]; const local = layOut(results as never, null); - expect(local.map((i) => [i.n, i.id, i.open])).toEqual([[1, "b", true], [2, "d", true], [3, "c", true], [4, "a", false]]); - expect(FREE_SHOWN).toBe(3); + expect(local.map((i) => [i.n, i.id, i.open])).toEqual([[1, "b", true], [2, "d", false], [3, "c", false], [4, "a", false]]); + expect(FREE_SHOWN).toBe(1); expect(local[0]).toMatchObject({ why: "why b", step: "fix b", prompt: "prompt b", fromModel: false }); // the server's plan: its order, its words where it has some, the catalog's where it has none, the rest held back const plan = { items: [{ id: "c", why: "for your shop", step: "do this", prompt: "tell it" }, { id: "a", why: null, step: null, prompt: null }, { id: "zzz", why: "x", step: "x", prompt: "x" }], lockedIds: ["d", "b"] }; @@ -116,13 +119,12 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { expect(w.toSite().every((c) => c.method === "GET")).toBe(true); expect(out).toMatch(/\d+ of \d+ checks pass\. \d+ to do/); expect(out).toContain("--no-model: nothing was sent"); - // the empty shell and the placeholder title are found, each with what was measured + // the first one in full, with what was measured; the rest counted, with the way to Pro (the owner's call, 2026-11-12) expect(out).toMatch(/1 {2}\S/); - expect(out).toContain('"Vite + React"'); expect(out).toContain("Ask your agent"); - // three in full; the rest by title, with where the plans are - expect(out.match(/^ {6}Ask your agent$/gm)).toHaveLength(3); - expect(out).toContain("come with the Team plan"); + expect(out.match(/^ {6}Ask your agent$/gm)).toHaveLength(1); + expect(out).not.toContain('"Vite + React"'); + expect(out.replace(/\s+/g, " ")).toMatch(/ PRO \d+ more suggestions for this app: \d+ from your checks, each with a prompt for your coding agent\. Start: npx genclass upgrade/); // the last run is remembered outside the project, and the status shows it const notes = readNotes(dir); expect(notes.site).toBe("https://shop.example"); @@ -132,7 +134,7 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { expect(st.steps.find((s) => s.id === "suggestions")).toMatchObject({ state: "waiting" }); }); - it("signed in with --yes: the digest goes to genclass.dev, Free gets the plan for three, and no code or secret is in what was sent", async () => { + it("signed in with --yes: the digest goes to genclass.dev, Free gets the plan for one and the count of the rest, and no code or secret is in what was sent", async () => { const { env } = setup(); await account(env); const dir = project(); @@ -141,11 +143,11 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { const text = await printed(async () => (code = await suggest({ cwd: dir, url: "https://shop.example", yes: true, json: true }, deps(w)))); expect(code).toBe(0); const j = JSON.parse(text); - expect(j).toMatchObject({ site: "https://shop.example", source: "checks", plan: { id: "free", name: "Free" }, upgrade_url: `${HOST}/account/billing` }); - expect(j.failed.length).toBeGreaterThan(5); - expect(j.failed.slice(0, 3).every((f: any) => f.why && f.do && f.prompt && !f.locked)).toBe(true); - expect(j.failed.slice(3).every((f: any) => f.locked === true && !("why" in f) && Array.isArray(f.evidence))).toBe(true); - expect(j.failed.map((f: any) => f.n)).toEqual(j.failed.map((_: unknown, i: number) => i + 1)); + expect(j).toMatchObject({ site: "https://shop.example", source: "checks", plan: { id: "free", name: "Free" }, upgrade_url: `${HOST}/account/billing?plan=team&from=cli`, ideas: [], designs: [] }); + // Free: the first one in full, the rest counted and not listed + expect(j.failed).toHaveLength(1); + expect(j.failed[0]).toMatchObject({ n: 1, why: expect.any(String), do: expect.any(String), prompt: expect.any(String) }); + expect(j.more.checks).toBeGreaterThan(4); // heaviest first expect(j.failed[0].weight).toBe(5); const ids = new Set(CHECKS.map((c: { id: string }) => c.id)); @@ -155,15 +157,66 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { expect(sent.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(["POST /api/cli/suggest"]); const body = sent[0]!.body; expect(Object.keys(body).sort()).toEqual(["app", "checks", "passed", "unknown"]); - expect(body.app).toEqual({ name: "shop", framework: "Vite + React", site: "https://shop.example", payments: "stripe", hosting: null }); - expect(body.checks.map((c: any) => c.id).sort()).toEqual(j.failed.map((f: any) => f.id).sort()); + expect(Object.keys(body.app).sort()).toEqual(["about", "framework", "hosting", "name", "payments", "site"]); + expect(body.app).toMatchObject({ name: "shop", framework: "Vite + React", site: "https://shop.example", payments: "stripe", hosting: null }); + expect(body.checks).toHaveLength(j.failed.length + j.more.checks); // the secret in .env was found, and its value went nowhere: not to the server, not into the output - expect(j.failed.some((f: any) => f.id === "env-committed" || f.id === "secrets-in-repo" || f.id === "env-example")).toBe(true); + expect(body.checks.some((f: any) => f.id === "env-committed" || f.id === "secrets-in-repo" || f.id === "env-example")).toBe(true); for (const blob of [JSON.stringify(sent), text]) expect(blob.includes("kq7Zr2mVx9Lp4Ws8Tn3Yb6Hd")).toBe(false); // no file's contents either expect(JSON.stringify(sent)).not.toMatch(/createRoot|react-dom\/client|
{ + const { env, sql } = setup({ OPENAI_API_KEY: "sk-test-0123456789abcdef0123" }); + const me = await account(env); + const dir = project(); + let runs = 0; + const design = { title: "Bold outdoor retail", inspired_by: "https://www.patagonia.com", why: "A store that sells gear for the cold, like yours.", prompt: "Redesign the home page of this Vite and React shop in the style of a premium outdoor store: full-width photography, a short headline over it, large product cards in a three-column grid. ".repeat(3) }; + const model = (init: RequestInit) => { + // the server checks that a design's site answers before it keeps the design + if ((init.method ?? "GET").toUpperCase() === "GET") return new Response("ok", { status: 200 }); + runs++; + const failed = JSON.parse(JSON.parse(String(init.body)).messages[1].content).failed as { id: string }[]; + const items = failed.map((c) => ({ id: c.id, why: `For this shop: ${c.id}.`, step: `First: ${c.id}.`, prompt: `Agent: ${c.id}.` })); + const ideas = ["A size guide for sleds", "Shipping times on the product page"].map((t) => ({ title: t, why: `why ${t}`, step: `step ${t}`, prompt: `prompt ${t}` })); + return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ items, ideas, designs: [design] }) } }] }), { status: 200, headers: { "content-type": "application/json" } }); + }; + const w = world(env, model); + // Free: the first suggestion, and the rest counted, ideas and designs included, with Pro's trial + const free = (await printed(async () => suggest({ cwd: dir, url: "https://shop.example", yes: true }, deps(w)))).replace(/\s+/g, " "); + expect(free).toMatch(/ PRO \d+ more suggestions for this app: \d+ from your checks, 2 ideas from the model, 1 website design inspired by sites like yours, each with a prompt for your coding agent\. 3 days free, then \$15 \/ project \/ month\. Cancel any time\. Start: npx genclass upgrade/); + expect(free).not.toContain("A size guide for sleds"); + expect(free).not.toContain("patagonia"); + expect(runs).toBe(1); + // Free again: the plan from the first run, said so, and no new model run + const again = (await printed(async () => suggest({ cwd: dir, url: "https://shop.example", yes: true }, deps(w)))).replace(/\s+/g, " "); + expect(again).toMatch(/This is your plan from \w+ \d+: on Free it stays this one\. Pro writes it again every time you run this\./); + expect(runs).toBe(1); + // Pro (here GenClass's grant): every item, the ideas marked as not measured, the design with its site and its whole prompt + setPlan(sql, me.account.id, "free", { comp_until: new Date(now().getTime() + 86_400_000).toISOString(), comp_plan: "team" }); + const cols = process.stdout.columns; + process.stdout.columns = 58; + let paid = ""; + try { + paid = await printed(async () => suggest({ cwd: dir, url: "https://shop.example", yes: true, all: true }, deps(w))); + } finally { + process.stdout.columns = cols; + } + expect(tooWide(paid)).toEqual([]); + const flat = paid.replace(/\s+/g, " "); + expect(flat).toContain("Ideas from the model Not measured: judged from your app and its site."); + expect(flat).toContain("A size guide for sleds"); + expect(flat).toContain("Website designs The model's choice of sites that fit what you do."); + expect(flat).toContain("Bold outdoor retail Inspired by https://www.patagonia.com"); + expect(flat).toContain(`Prompt for Claude (copy it all) ${design.prompt.trim()}`); + expect(flat).not.toContain("more suggestions for this app"); + expect(runs).toBe(2); + const j = JSON.parse(await printed(async () => suggest({ cwd: dir, url: "https://shop.example", yes: true, json: true }, deps(w)))); + expect(j.designs).toEqual([{ title: design.title, inspired_by: "https://www.patagonia.com", why: design.why, prompt: design.prompt.trim() }]); + expect(j.ideas.map((i: any) => i.title)).toEqual(["A size guide for sleds", "Shipping times on the product page"]); + }); + it("with a model on the server and Team: the model's order and words for every item", async () => { const { env, sql } = setup({ OPENAI_API_KEY: "sk-test-0123456789abcdef0123" }); const me = await account(env); @@ -176,7 +229,7 @@ describe("genclass suggest: end to end (src/suggest/index.mjs header)", () => { }; const w = world(env, model); const j = JSON.parse(await printed(async () => suggest({ cwd: dir, url: "https://shop.example", yes: true, json: true }, deps(w)))); - expect(j).toMatchObject({ source: "model", plan: { id: "team", name: "Team" }, upgrade_url: null }); + expect(j).toMatchObject({ source: "model", plan: { id: "team", name: "Pro" }, upgrade_url: null, more: { checks: 0, ideas: 0, designs: 0 } }); expect(j.failed.every((f: any) => !f.locked && f.why === `For this shop: ${f.id}.` && f.do === `First: ${f.id}.` && f.prompt === `Agent: ${f.id}.`)).toBe(true); // the model's order, which was the reverse of what was sent const sentIds = w.toGenclass()[0]!.body.checks.map((c: any) => c.id); diff --git a/telemetry-worker/README.md b/telemetry-worker/README.md index f16c03dd..dd223205 100644 --- a/telemetry-worker/README.md +++ b/telemetry-worker/README.md @@ -542,7 +542,7 @@ the free features"; one thing stays free, finding, seeing and fixing the problem the dashboard and the API say which plan has them and where the plans are (403 with `upgrade_url`; the stats carry `locked.analytics`). What is collected and kept does not change with the plan: every app's batches are stored and counted, visitor analytics included, and kept 90 days, so an upgrade shows what was counted before it. -- **Team** (US$15 a project a month, `per_project` since 2026-11-12, US$15 a seat on 2026-11-11; lookup key +- **Pro** (named Team until 2026-11-12; plan id `team`; US$15 a project a month, `per_project` since 2026-11-12, US$15 a seat on 2026-11-11; a 3-day trial (`trial_days`) for an account's first subscription, card up front, `src/billing.ts` trialFor; lookup key `genclass_team_monthly`, tax code `txcd_10103001`, software as a service, business use; standard Checkout, not Managed Payments, with the "Add promotion code" field): everything in Free, 90 days shown, unlimited live fixes, visitor analytics, forwarding, export, the GitHub App on private repositories (`github_private`) and any number of @@ -553,6 +553,7 @@ the free features"; one thing stays free, finding, seeing and fixing the problem the apps the account had. Free covers an account's first 3 apps and 100 problems fixed live a month for each (`src/usage.ts`; past it ingest's reply tells the runtime to observe only until the month ends); a 4th app on Free is locked until it has a plan. Promotion codes are made in the Stripe dashboard (Product catalog, Coupons). +- **Free since 2026-11-12** (the owner's "limit free fully"): the totals only. Stats answers for an app without the `problems` entitlement go through `src/dashboard.ts` stripProblems (the browser's dashboard, `/api/cli/app` and private links): every breakdown but visitor analytics', the latest events and the per-kind chart are left out, and `locked.problems` `{ plan, upgrade_url, trial_days, price }` says where Pro starts; the dashboard page shows one banner with the trial button instead of empty lists. An own app on Free with no choice made opens its plan page first. - **Enterprise** (no price: agreed by email with the contact address): everything in Team, a year of history, your own classifier (`custom_model`: a person from GenClass trains a model on the app's traffic, bug reports or labelled examples and ships it as private model files), and @@ -1172,7 +1173,7 @@ sign-in, `/api/me`, `/api/account/projects`) and four of its own (`src/cli.ts`; |---|---| | `GET /api/cli/overview` | CLI session or browser session: the account, its plan `{ id, name }`, its apps, `suggestions: { per_day, shown, used_today, model }` and where the GitHub App's install and connect pages are | | `GET /api/cli/github?repo=owner/name` | CLI session or browser session: what the GitHub App is doing on that repository (`known`, `installed`, `evidence`, `prs`, `last`, `private`, `needs_plan`, `paused`). GitHub is asked nothing: the answer comes from what GitHub told this account in the last 30 minutes (Connect GitHub, or Continue with GitHub after installing). Without such an answer: `known: false` and `connect_url` | -| `POST /api/cli/suggest` | CLI session only. The body is a digest of the checks that failed on an app (`src/suggest.ts` `readDigest`: at most 60 checks, each an id, area, title, weight and at most 3 evidence lines of 160 characters, plus the app's name, framework, address, payment provider and host; at most 64 KB; anything else is dropped). The answer is the plan: `items` in order, each `{ id, why, step, prompt }`, `locked_ids` for the items the account's plan does not show, `source` (`model` or `checks`), `remaining_today` | +| `POST /api/cli/suggest` | CLI session only. The body is a digest of the checks that failed on an app (`src/suggest.ts` `readDigest`: at most 60 checks, each an id, area, title, weight and at most 3 evidence lines of 160 characters, plus the app's name, framework, address, payment provider and host; at most 64 KB; anything else is dropped). The answer is the plan: `items` in order, each `{ id, why, step, prompt }`, `locked_ids` for the items the account's plan does not show, `source` (`model` or `checks`), `remaining_today`. Since 2026-11-12 (the owner's call): the digest's `app` also has `about` (the site's title and description, 300 characters); the model also writes `ideas` (up to 20, its own, never shown as measured) and `designs` (up to 4, each `{ title, inspired_by, why, prompt }` with a real site the server checks answers over HTTPS, and a complete prompt for a coding agent); the answer is kept per account and app (`suggest_results`, migration 0019, the app keyed by a hash of its address or name). A plan that shows part of it (Free, `suggestions.shown` 1) gets the kept answer again on later runs (`cached_at`, no new model run), its first item, the rest as `locked`/`locked_ids`, and `ideas_locked`/`designs_locked` as counts with `ideas` and `designs` empty; `upgrade_plan` `{ name, price, trial_days }` and `upgrade_url` say where Pro starts. Pro gets everything | | `GET /api/cli/app?id=&range=7d` | CLI session or browser session: one app's dashboard as data, for `genclass dashboard` and the views beside it (`problems`, `analytics`, `events`, `health`, `agent`, `report`, `fix`). It is what `/api/account/projects//stats` answers (same plan rules: the ranges a plan shows, visitor analytics left out without them, `402` when the plan has no dashboard), with the 50 most recent findings and `dashboard_url`. The app is the account's own or one of a team it is in; anything else is `404`. A CLI session reads it only if it was created at or after `CLI_DASHBOARD_FROM` (`src/cli.ts`), when the approval page began to say "show your apps' dashboards"; an older one gets `403` with `reauth: true`, and the command asks the person to sign in again. `/api/account/projects//stats` stays browser only | | `GET /api/cli/account` | CLI session approved at or after `CLI_ACCOUNT_FROM` (`src/cli.ts`; the approval page lists what the routes from here down do; an older session gets `403` with `reauth: true`, a browser session `403`). The plan `{ id, name, price, summary }`, why the account has it (`access`), `limits`, what is `used`, what the plan `includes`, the subscription's state, the team (members, open invites, memberships) and, on Free, the plan it can start (`offer`). Nothing about how the account signs in or pays | | `POST /api/cli/team/invite` | the same session: a single-use invite link for the account's team, shown once (`src/team.ts createInvite`: the seats, the limits and the errors of `POST /api/team/invites`) | @@ -1181,7 +1182,7 @@ sign-in, `/api/me`, `/api/account/projects`) and four of its own (`src/cli.ts`; | `GET /api/cli/events?id=&from&to&format&cursor&limit` | the same session, the app's owner: one page of the app's stored events (`src/billing.ts exportFor`: the export's plan rule, history, formats, cursor header and per-minute limit) | | `PUT /api/cli/funnels?id=` | the same session, the app's owner: the app's funnels, whole (`src/dashboard.ts putFunnels`; a plan with visitor analytics) | | `POST /api/cli/link?id=` | the same session, the app's owner: a new private dashboard link, shown once; it replaces any earlier one (`src/auth.ts makeLinkFor`) | -| `POST /api/cli/ask` | the same session. `{ id, range?, question, history? }` (a question of at most 600 characters, at most three earlier `{ q, a }` turns, 16 KB; anything else is dropped). The server reads the app's stats under the dashboard's plan rules, sends the question and a digest of them to the model (`src/ask.ts appDigest`: names, page paths and counts; visitor numbers only with the plan that has them; never a key, an email or an id) and answers `{ answer, why, range, remaining_today, per_day, upgrade_url }`; `answer` is null with `why` `no-model`, `limit` or `failed`. One model run of the account's day is taken (the count `suggest` uses); neither the question nor the answer is stored or logged | +| `POST /api/cli/ask` | the same session. `{ id, range?, question, history? }` (a question of at most 600 characters, at most three earlier `{ q, a }` turns, 16 KB; anything else is dropped). The server reads the app's stats under the dashboard's plan rules, sends the question and a digest of them to the model (`src/ask.ts appDigest`: names, page paths and counts; visitor numbers only with the plan that has them; never a key, an email or an id) and answers `{ answer, why, range, remaining_today, per_day, upgrade_url }`; `answer` is null with `why` `no-model`, `limit` or `failed`. One model run of the account's day is taken (the count `suggest` uses); neither the question nor the answer is stored or logged. An app whose plan lacks `ask` (Free since 2026-11-12) gets 403 `{ locked: true, needs_plan: true, plan, upgrade_url, trial_days, price }` and takes no model run | What decides the answer: diff --git a/telemetry-worker/migrations/0019_suggest_results.sql b/telemetry-worker/migrations/0019_suggest_results.sql new file mode 100644 index 00000000..3cf95d25 --- /dev/null +++ b/telemetry-worker/migrations/0019_suggest_results.sql @@ -0,0 +1,15 @@ +-- genclass suggest's last answer per app (the owner's call, 2026-11-12): the measured checks in the model's order with +-- its reasons, steps and prompts, the model's own ideas (not measured) and its website design ideas (each inspired by +-- a real site, with a full prompt to paste into a coding agent). Free sees the first item and the counts, and is +-- shown this same answer again on every run until the account pays; Pro sees all of it. One row per account and +-- app, replaced by the next answer; deleted with the account. The digest it came from is not kept. +-- Apply before deploying the worker that reads it: +-- npx --yes wrangler@4 d1 migrations apply genclass-dashboard --remote + +CREATE TABLE suggest_results ( + account_id TEXT NOT NULL, + app_key TEXT NOT NULL, -- sha256 of the app's site address (else its name), so one app has one answer + created TEXT NOT NULL, + body TEXT NOT NULL, -- JSON: { source, items, ideas, designs } + PRIMARY KEY (account_id, app_key) +); diff --git a/telemetry-worker/src/auth.ts b/telemetry-worker/src/auth.ts index e546be78..0313181c 100644 --- a/telemetry-worker/src/auth.ts +++ b/telemetry-worker/src/auth.ts @@ -70,6 +70,7 @@ import { sha256Hex, stats, stripAnalytics, + stripProblems, type D1Like, type Project, } from "./dashboard.js"; @@ -81,7 +82,7 @@ import { forgetAccount as forgetAccountGithub, unlinkProject } from "./github/st import { json, mediaType, readCapped } from "./http.js"; import { dashboardPage, ownEndpoint, securityHeaders, setupCommand } from "./pages.js"; import { DEVICE_FAILURES, DEVICE_MS, DUMMY_HASH, FAILURE_WINDOW_MS, FREE_FAILURES, MAX_DELAY_MS, PASSWORD_MAX, delayAfter, hashPassword, needsRehash, passwordProblem, verifyPassword, waitText } from "./password.js"; -import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, keepDays, PAID_PLANS, pickRange, projectAccess, viewRanges } from "./plans.js"; +import { accountAccess, accountBilling, canBuy, firstPlanWith, FREE, hasAnalytics, hasDashboard, hasProblems, keepDays, PAID_PLANS, pickRange, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { activeOwners, forgetTeam, membershipsOf } from "./team.js"; import { cancelForDeletion } from "./stripe.js"; import { @@ -1787,6 +1788,7 @@ async function deleteAccount(c: C): Promise { const owned = "SELECT id FROM projects WHERE account_id = ?"; // `genclass suggest`'s count of model runs goes with the account (on its own: its table comes with migration 0015) await c.db.prepare("DELETE FROM suggest_usage WHERE account_id = ?").bind(id).run().catch(() => undefined); + await c.db.prepare("DELETE FROM suggest_results WHERE account_id = ?").bind(id).run().catch(() => undefined); await c.db.batch([ // paid features of every app in the account (apps that keep a private link leave without them) c.db.prepare(`DELETE FROM integrations WHERE project_id IN (${owned})`).bind(id), @@ -2070,7 +2072,9 @@ export async function routeAuth(request: Request, url: URL, path: string, env: E const s = await stats(c.db, p, range, c.now); const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null); // visitor analytics are left out for a plan without them, and counted all the same (src/dashboard.ts stripAnalytics) - return json(200, { ...(hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` })), usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); + const planUrl = `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`; + const shown = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }); + return json(200, { ...(hasProblems(access) ? shown : stripProblems(shown, problemsLock(planUrl))), usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); } const fun = /^\/api\/account\/projects\/([^/]+)\/funnels$/.exec(path); if (fun) { diff --git a/telemetry-worker/src/billing-pages.ts b/telemetry-worker/src/billing-pages.ts index 9d3b1ebb..c3e3cfb3 100644 --- a/telemetry-worker/src/billing-pages.ts +++ b/telemetry-worker/src/billing-pages.ts @@ -230,6 +230,8 @@ export interface ProjectPlanView { /** The project's own subscription, as the webhook wrote it. */ sub: { status: string | null; periodEnd: string | null; cancels: boolean }; usage: { used: number; limit: number | null; observe_only: boolean; resets: string } | null; + /** Days free this account's first plan starts with (billing.ts trialFor); 0: none. */ + trialDays?: number; live: boolean; testMode: boolean; /** Back from Stripe Checkout (?done=1). */ @@ -280,8 +282,8 @@ export function projectPlanPage(nonce: string, v: ProjectPlanView): string { const test = v.testMode && v.live ? '

Stripe test mode. No real money moves. Pay with card 4242 4242 4242 4242, any future date and any CVC.

' : ""; const features = (offer?.features ?? []).filter((f) => f.available).map((f) => `
  • ${esc(f.text)}
  • `).join(""); const card = offer && !paid && a.source !== "open" - ? `

    ${esc(offer.name)}

    ${esc(priceText(offer))}

    ${offer.trial_days ? `

    ${offer.trial_days} days free on your first paid project. Cancel any time.

    ` : ""}

    ${esc(offer.summary)}

      ${features}
    -
    ${v.live ? `` : 'Paid plans are not on sale yet.'}
    + ? `

    ${esc(offer.name)}

    ${esc(priceText(offer))}

    ${esc(offer.summary)}

      ${features}
    +
    ${v.live ? `${v.trialDays ? `

    ${v.trialDays} days free, then ${esc(priceText(offer))} for ${name}. Cancel before the trial ends and you pay nothing.

    ` : ""}` : 'Paid plans are not on sale yet.'}
    ${a.locked || a.source !== null ? "" : `

    Try for free (limited)

    @@ -371,7 +373,7 @@ ${notice}${test} user: { label, sub: orgName(label) }, ...orgRail("billing"), railLabel: "Organization", - css: DASH_CSS + ORG_CSS + BILLING_CSS + ".plan.big{max-width:560px;padding:34px 32px 30px}.plan.big h3{font-size:34px;letter-spacing:-.01em}.plan.big .price{font-size:22px;font-weight:600;margin-top:6px}.plan.big .sum{font-size:15px}.plan.big li{font-size:15px}.plan.big .act .btn{width:100%;justify-content:center;padding:15px 18px;font-size:17px}.free{margin-top:12px;font-size:11px;max-width:560px;text-align:center}.linkish{background:none;border:0;padding:0;font:inherit;font-size:11px;color:var(--ink3);text-decoration:underline;cursor:pointer}.linkish:disabled{opacity:.6;cursor:default}dialog.why{margin:auto;max-height:calc(100vh - 32px);overflow:auto;max-width:460px;width:calc(100% - 32px);border:1px solid var(--rule);border-radius:14px;background:var(--paper);color:var(--ink);padding:24px 24px 20px}dialog.why::backdrop{background:rgba(0,0,0,.62)}dialog.why h2{font-size:21px;margin:0 0 4px}dialog.why fieldset{border:0;padding:0;margin:14px 0 6px}dialog.why label{display:flex;gap:10px;align-items:center;padding:7px 0;font-size:15px;cursor:pointer}dialog.why textarea{margin-top:6px}.why-act{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}", + css: DASH_CSS + ORG_CSS + BILLING_CSS + ".plan.big{max-width:560px;padding:34px 32px 30px}.plan.big h3{font-size:34px;letter-spacing:-.01em}.plan.big .price{font-size:22px;font-weight:600;margin-top:6px}.plan.big .sum{font-size:15px}.plan.big li{font-size:15px}.plan.big .act .btn{width:100%;justify-content:center;padding:15px 18px;font-size:17px}.free{margin-top:12px;font-size:11px;max-width:560px;text-align:center}.linkish{background:none;border:0;padding:0;font:inherit;font-size:11px;color:var(--ink3);text-decoration:underline;cursor:pointer}.linkish:disabled{opacity:.6;cursor:default}.trialnote{margin:10px 0 0;font-size:13px;color:var(--ink3);text-align:center;width:100%}dialog.why{margin:auto;max-height:calc(100vh - 32px);overflow:auto;max-width:460px;width:calc(100% - 32px);border:1px solid var(--rule);border-radius:14px;background:var(--paper);color:var(--ink);padding:24px 24px 20px}dialog.why::backdrop{background:rgba(0,0,0,.62)}dialog.why h2{font-size:21px;margin:0 0 4px}dialog.why fieldset{border:0;padding:0;margin:14px 0 6px}dialog.why label{display:flex;gap:10px;align-items:center;padding:7px 0;font-size:15px;cursor:pointer}dialog.why textarea{margin-top:6px}.why-act{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}", body, script: CLIENT_JS + script, }); diff --git a/telemetry-worker/src/billing.ts b/telemetry-worker/src/billing.ts index 3697a934..844e475f 100644 --- a/telemetry-worker/src/billing.ts +++ b/telemetry-worker/src/billing.ts @@ -3,7 +3,7 @@ // POST /api/billing/checkout {plan: "", seats?} browser session + Origin; {url} of Stripe Checkout (with // switch: true when it replaces the plan already paid); `seats` // (1 to 200, default 1) for a plan sold per seat (Team) -// POST /api/billing/trial 410: GenClass has no trial any more (the dashboard is free) +// POST /api/billing/trial 410: the trial of 2026-10 is gone; Pro's trial starts at Checkout // POST /api/billing/portal browser session + Origin; {url} of the Billing Portal // POST /api/billing/webhook Stripe only: signature, no session (src/stripe.ts) // GET /api/billing browser session; the plan and the subscription @@ -28,7 +28,7 @@ import { accountContext, accountProjects, authenticate, csrfFail, fail, originOk import { billingPage, projectPlanPage } from "./billing-pages.js"; import { liveFixUsage } from "./usage.js"; import type { Env, HandleOptions, RateLimiterLike } from "./collector.js"; -import { randomBase62, sha256Hex, type Project } from "./dashboard.js"; +import { randomBase62, sha256Hex, type D1Like, type Project } from "./dashboard.js"; import { decodeCursor, exportResponse, PAGE_MAX, parseTime } from "./export.js"; import { checkConfig, claimTestSend, deliver, integrationRows, integrationsKey, isKind, maskConfig, openConfig, sealConfig, testRecord, type Kind } from "./forward.js"; import { json, readCapped } from "./http.js"; @@ -64,6 +64,7 @@ import { missingCustomer, modeNote, notifyOwner, + openTrialElsewhere, portalSession, sharedCustomer, StripeError, @@ -146,7 +147,7 @@ export async function routeBilling(request: Request, url: URL, path: string, env case "/api/billing/portal": return m === "POST" ? portal(c, f) : method(); case "/api/billing/trial": - return m === "POST" ? fail(410, `GenClass has no trial any more: the dashboard is free; see ${c.cfg.origin}/account/billing`) : method(); + return m === "POST" ? fail(410, `a trial starts at Checkout, on an app's plan page: ${c.cfg.origin}/account/billing`) : method(); case "/account/trial": // genclass.dev's pricing page linked here until 2026-10-19 (bookmarks, caches): the account page return get ? redirect((await webSession(c)) ? "/account" : `/signup?next=${encodeURIComponent("/account")}`) : method(); @@ -192,6 +193,7 @@ export async function routeBilling(request: Request, url: URL, path: string, env const row = await c.db.prepare("SELECT subscription_status, current_period_end, cancel_at_period_end, plan_chosen FROM projects WHERE id = ?").bind(p.id).first<{ subscription_status: string | null; current_period_end: string | null; cancel_at_period_end: number | null; plan_chosen: string | null }>(); const apps = (await accountProjects(c, a.account.id)).map((x) => ({ id: x.id!, name: x.name })); const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null); + const trialDays = PAID_PLANS[0] && access.source === null ? await trialFor(c.db, a.account.id, PAID_PLANS[0]) : 0; return page(200, (n) => projectPlanPage(n, { account: { id: a.account.id, email: a.account.email, githubLogin: a.account.github_login }, @@ -200,6 +202,7 @@ export async function routeBilling(request: Request, url: URL, path: string, env access, sub: { status: row?.subscription_status ?? null, periodEnd: row?.current_period_end ?? null, cancels: !!row?.cancel_at_period_end }, usage, + trialDays, live: canBuy(env, a.account), testMode: stripeMode(env) === "test", done: url.searchParams.get("done") === "1", @@ -224,6 +227,8 @@ export async function routeBilling(request: Request, url: URL, path: string, env const open = (await accountProjectsAccess(c.db, a.account.id, env, c.now)).find((x) => x.row.id && x.access.plan.id === FREE.id && x.access.source === null && !x.access.locked) ?? (await accountProjectsAccess(c.db, a.account.id, env, c.now)).find((x) => x.row.id && x.access.locked); if (open) return redirect(`/account/billing?project=${encodeURIComponent(open.row.id!)}${go ? "&from=cli" : ""}`); + // no app yet (the pricing page's trial button, from a new account): make one first; its plan page follows + if (go && !(await accountProjects(c, a.account.id)).some((x) => x.account_id === a.account.id)) return redirect("/account/new"); } const access = await accountAccess(c.db, a.account.id, env, c.now); const apps = (await accountProjects(c, a.account.id)).map((p) => ({ id: p.id!, name: p.name })); @@ -443,11 +448,27 @@ async function portal(c: C, f: Fetch): Promise { } } +/** + * The days free a purchase of `plan` starts with: the plan's trial_days (Pro: 3) for an account that has never had a + * subscription, none of its apps and not the account (D1), and none on its Stripe customer (`stripeSubs`, any + * status, when the caller has asked Stripe); else 0. One trial per account: a cancelled trial does not start another. + */ +export async function trialFor(db: D1Like, accountId: string, plan: Plan, stripeSubs = 0): Promise { + const days = Number.isInteger(plan.trial_days) && plan.trial_days! > 0 ? plan.trial_days! : 0; + if (!days || stripeSubs > 0) return 0; + const r = await db + .prepare("SELECT (SELECT COUNT(*) FROM projects WHERE account_id = ? AND subscription_id IS NOT NULL) + (SELECT COUNT(*) FROM accounts WHERE id = ? AND subscription_id IS NOT NULL) AS n") + .bind(accountId, accountId) + .first<{ n: number }>() + .catch(() => ({ n: 1 })); + return Number(r?.n ?? 1) ? 0 : days; +} + /** * Checkout for one project: the project must be the account's and not already on that plan; the Stripe customer is * the account's own (the same checks as the account's checkout: GenClass's, holding nothing else). One subscription * of quantity 1 per project, its metadata naming the project (src/stripe.ts checkoutSession, applyProjectSubscription); - * the account's other subscriptions are never touched. + * the account's other subscriptions are never touched. The first one starts with the plan's trial (trialFor). */ async function checkoutProject(c: C, f: Fetch, a: Auth, plan: Plan, projectId: string): Promise { const p = await ownedProject(c, a.account.id, projectId); @@ -474,8 +495,12 @@ async function checkoutProject(c: C, f: Fetch, a: Auth, plan: Plan, projectId: s const subs = own.state === "ok" ? own.subs : []; if (subs.some((s) => projectOf(s) === p.id && HOLDING_STATUSES.has(s.status))) return fail(409, `a plan for ${p.name} is already going through; reload this page in a minute`); } - const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, project: { id: p.id }, trialDays: first ? plan.trial_days : 0 }); - return json(200, { url }); + // one trial per account: none when the customer has had a subscription (first, above), GenClass's records say the + // account has had one (trialFor), or another project's open Checkout already holds it + const elsewhere = customer.fresh ? false : await openTrialElsewhere(c.env, f, customer.id, a.account.id, p.id).catch(() => true); + const trialDays = elsewhere ? 0 : await trialFor(c.db, a.account.id, plan, first ? 0 : 1); + const url = await checkoutSession(c.env, f, { account, customer: customer.id, plan, origin: c.cfg.origin, now: c.now, fresh: customer.fresh, project: { id: p.id }, trialDays }); + return json(200, { url, trial_days: trialDays || null }); } catch (e) { return stripeFail(e); } diff --git a/telemetry-worker/src/cli.ts b/telemetry-worker/src/cli.ts index 8dcfea16..bc654e38 100644 --- a/telemetry-worker/src/cli.ts +++ b/telemetry-worker/src/cli.ts @@ -43,16 +43,16 @@ import { answer, appDigest, MAX_ASK_BYTES, readQuestion } from "./ask.js"; import { accountContext, accountJson, accountProjects, authenticate, fail, lockedJson, makeLinkFor, ownedProject, projectJson, readableProject, readJson, upgradeJson, type Auth, type C } from "./auth.js"; -import { exportFor, integrationsFor, overBudget, tooMany } from "./billing.js"; +import { exportFor, integrationsFor, overBudget, tooMany, trialFor } from "./billing.js"; import type { Env, HandleOptions } from "./collector.js"; -import { putFunnels, stats, stripAnalytics } from "./dashboard.js"; +import { putFunnels, stats, stripAnalytics, stripProblems } from "./dashboard.js"; import { isKind, KINDS } from "./forward.js"; import { githubApp } from "./github/config.js"; import { repoStatusForAccount } from "./github/http.js"; import { json } from "./http.js"; -import { accountAccess, accountBilling, accountProjectsAccess, canBuy, entitled, firstPlanWith, FREE, hasAnalytics, hasDashboard, PAID_PLANS, pickRange, priceText, projectAccess, viewRanges, type Access } from "./plans.js"; +import { accountAccess, accountBilling, accountProjectsAccess, canBuy, entitled, firstPaidWith, firstPlanWith, FREE, hasAnalytics, hasAsk, hasDashboard, hasProblems, PAID_PLANS, pickRange, priceText, problemsLock, projectAccess, viewRanges, type Access } from "./plans.js"; import { liveFixUsage } from "./usage.js"; -import { askModel, byWeight, claimRun, hasModel, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem } from "./suggest.js"; +import { appKey, askModel, byWeight, claimRun, hasModel, keepAnswer, lastAnswer, MAX_SUGGEST_BYTES, readDigest, suggestionLimits, usedToday, type PlanItem, type SuggestAnswer } from "./suggest.js"; import { createInvite, isTeamError, membershipsOf, teamOf } from "./team.js"; /** A repository's full name as GitHub writes one. */ @@ -71,7 +71,7 @@ async function overview(c: C): Promise { projects: await withPlans(c, a.account.id), github: { app: gh !== null, install_url: gh ? `${c.cfg.origin}/github/install` : null, connect_url: gh?.clientId ? `${c.cfg.origin}/github/connect` : null }, billing_url: `${c.cfg.origin}/account/billing`, - offer: offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + offer: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), }); } @@ -97,7 +97,7 @@ async function withPlans(c: C, accountId: string): Promise | null { +async function offerFor(c: C, account: { id: string; email: string | null }, access: Access, projects?: { row: { id?: string | null }; access: Access }[]): Promise | null> { const plan = PAID_PLANS[0]; if (!plan || !canBuy(c.env, account)) return null; // a plan sold per project is offered while one of the account's apps is on Free, and only once it has one (the @@ -111,6 +111,7 @@ function offerFor(c: C, account: { id: string; email: string | null }, access: A summary: plan.summary, features: plan.features.filter((f) => f.available && !/^Everything in /i.test(f.text)).map((f) => f.text).slice(0, 12), checkout_url: one ? `${c.cfg.origin}/account/billing?project=${encodeURIComponent(one)}&from=cli` : `${c.cfg.origin}/account/billing?plan=${plan.id}&start=1`, + trial_days: (await trialFor(c.db, account.id, plan)) || null, free: { name: FREE.name, apps: FREE.limits.apps, history_days: FREE.limits.history_days, suggestions_shown: suggestionLimits(FREE).shown, live_fixes: FREE.limits.live_fixes_per_month ?? null }, }; } @@ -145,7 +146,10 @@ async function app(c: C): Promise { if (!hasDashboard(access)) return lockedJson(c, access, p.id); const range = pickRange(c.url.searchParams.get("range"), viewRanges(access.plan)); const s = await stats(c.db, p, range, c.now); - const view = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }); + const planUrl = `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`; + const seen = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: `${c.cfg.origin}/account/billing` }); + // Free sees the totals only: the problems, pages, activity and fix prompts come with Pro (the browser's rule too) + const view = hasProblems(access) ? seen : stripProblems(seen, problemsLock(planUrl)); const usage = await liveFixUsage(c.db, p.token, access.plan, c.now).catch(() => null); return json(200, { ...view, recent: view.recent.slice(0, APP_RECENT), dashboard_url: projectJson(c, p).dashboard_url, usage, plan: { id: access.plan.id, name: access.plan.name, source: access.source }, plan_url: `${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}` }); } @@ -160,20 +164,37 @@ async function suggest(c: C): Promise { if (typeof digest === "string") return fail(400, digest); const access = await accountAccess(c.db, a.account.id, c.env, c.now); const lim = suggestionLimits(access.plan); + // Free (a plan that shows part of the answer) is shown the answer it got the first time for this app, again, until + // the account pays (the owner's call, 2026-11-12): no new model run, the same first item and the same counts + const partial = lim.shown !== null; + const key = await appKey(digest); + const kept = partial ? await lastAnswer(c.db, a.account.id, key) : null; - // the model, when the server has one and the account has a run left today; the checks' own order otherwise - let plan: PlanItem[] | null = null; + let answer: (SuggestAnswer & { created?: string }) | null = kept; + let created: string | null = kept?.created ?? null; let used: number | null = null; let limited = false; - if (hasModel(c.env) && digest.checks.length) { + if (!answer && hasModel(c.env) && (digest.checks.length || digest.app.site || digest.app.about)) { used = await claimRun(c.db, a.account.id, c.now, lim.per_day); - if (used === null) limited = (await usedToday(c.db, a.account.id, c.now)) >= lim.per_day; - else plan = await askModel(c.env, c.opts.fetch ?? ((u: string, i?: RequestInit) => fetch(u, i)), digest); + if (used === null) { + limited = (await usedToday(c.db, a.account.id, c.now)) >= lim.per_day; + // the day's runs are used up: the last answer kept for this app, if there is one + answer = await lastAnswer(c.db, a.account.id, key); + created = answer?.created ?? null; + } else { + answer = await askModel(c.env, c.opts.fetch ?? ((u: string, i?: RequestInit) => fetch(u, i)), digest); + if (answer) await keepAnswer(c.db, a.account.id, key, answer, c.now); + } } - const source = plan ? "model" : "checks"; - const order: PlanItem[] = plan ?? byWeight(digest.checks).map((x) => ({ id: x.id, why: null, step: null, prompt: null })); + const source = answer?.source ?? "checks"; + const order: PlanItem[] = answer?.items.length ? answer.items : byWeight(digest.checks).map((x) => ({ id: x.id, why: null, step: null, prompt: null })); + const ideas = answer?.ideas ?? []; + const designs = answer?.designs ?? []; const shown = lim.shown === null ? order.length : Math.min(order.length, lim.shown); const locked = order.slice(shown); + // what Free does not see is counted, never sent: the ideas and designs come back as numbers only + const hidden = partial ? ideas.length + designs.length : 0; + const pro = firstPaidWith("problems"); return json(200, { ok: true, source, @@ -182,7 +203,14 @@ async function suggest(c: C): Promise { items: order.slice(0, shown), locked: locked.length, locked_ids: locked.map((x) => x.id), - upgrade_url: locked.length || limited ? `${c.cfg.origin}/account/billing` : null, + ideas: partial ? [] : ideas, + designs: partial ? [] : designs, + ideas_locked: partial ? ideas.length : 0, + designs_locked: partial ? designs.length : 0, + // the answer was kept from an earlier run (Free sees it again; a plan out of runs for the day sees its last) + cached_at: created, + upgrade_url: locked.length || hidden || limited ? `${c.cfg.origin}/account/billing?plan=team&from=cli` : null, + upgrade_plan: pro ? { name: pro.name, price: priceText(pro), trial_days: pro.trial_days ?? null } : null, remaining_today: used === null ? Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now))) : Math.max(0, lim.per_day - used), limited, }); @@ -237,7 +265,7 @@ async function account(c: C): Promise { memberships: memberships.map((x) => ({ email: x.email, github_login: x.githubLogin, joined: x.joined, active: x.active })), }, billing_url: `${c.cfg.origin}/account/billing`, - offer: offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), + offer: await offerFor(c, a.account, access, await accountProjectsAccess(c.db, a.account.id, c.env, c.now)), }); } @@ -303,6 +331,11 @@ async function ask(c: C, f: Fetch): Promise { // the numbers the model gets are the ones this account's dashboard shows for the app: the same plan rules const access = await projectAccess(c.db, p, c.env, c.now); if (!hasDashboard(access)) return lockedJson(c, access, p.id); + // asking about the app comes with Pro (plans.json entitlement `ask`): Free is told which plan, and where + if (!hasAsk(access)) { + const lock = problemsLock(`${c.cfg.origin}/account/billing?project=${encodeURIComponent(p.id ?? "")}`); + return json(403, { ok: false, error: `asking about your app comes with ${lock.plan ?? "a paid plan"}`, locked: true, needs_plan: true, plan: lock.plan, upgrade_url: lock.upgrade_url, trial_days: lock.trial_days, price: lock.price }); + } const mine = await accountAccess(c.db, a.account.id, c.env, c.now); const lim = suggestionLimits(mine.plan); const left = async (): Promise => Math.max(0, lim.per_day - (await usedToday(c.db, a.account.id, c.now))); diff --git a/telemetry-worker/src/collector.ts b/telemetry-worker/src/collector.ts index 901595b3..19e46db8 100644 --- a/telemetry-worker/src/collector.ts +++ b/telemetry-worker/src/collector.ts @@ -42,6 +42,7 @@ import { sha256Hex, stats, stripAnalytics, + stripProblems, TOKEN_RE, type D1Like, type Range, @@ -56,7 +57,7 @@ import { afterBatch } from "./paid.js"; import { dashboardPage, notFoundPage, openSourcePage, ownEndpoint, securityHeaders, setupCommand, setupPage, startPage } from "./pages.js"; import { lockedLink } from "./locked.js"; import { applyLock, linkSession, linkUnlocked, lockKind, lockedJson, readBody, readLock, routeLink, startPasskeyOptions, unlockPage } from "./link-lock.js"; -import { firstPlanWith, hasAnalytics, hasDashboard, pickRange, projectAccess, viewRanges } from "./plans.js"; +import { firstPlanWith, hasAnalytics, hasDashboard, hasProblems, pickRange, problemsLock, projectAccess, viewRanges } from "./plans.js"; import { SAMPLE_PROJECT, SAMPLE_SECRET, sampleStats } from "./sample.js"; export const SCHEMA = "genclass-telemetry/1"; @@ -521,7 +522,9 @@ async function routeSite(request: Request, url: URL, path: string, env: Env, opt // a private link shows the ranges of the app's plan (Free's for an app without an account), never past 30 days const range = pickRange(url.searchParams.get("range"), viewRanges(access.plan).filter((x) => RANGES.includes(x))); const s = await stats(env.DB, p, range, now); - return json(200, hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: upgradeUrl(env) })); + const seen = hasAnalytics(access) ? s : stripAnalytics(s, { plan: firstPlanWith("analytics")?.name ?? null, upgrade_url: upgradeUrl(env) }); + // a private link's app has no account, so Free's rule: the totals only, and the way to an account with Pro + return json(200, hasProblems(access) ? seen : stripProblems(seen, problemsLock(upgradeUrl(env)))); } if (path === "/start") { diff --git a/telemetry-worker/src/dashboard.ts b/telemetry-worker/src/dashboard.ts index 991915ce..f3596e7e 100644 --- a/telemetry-worker/src/dashboard.ts +++ b/telemetry-worker/src/dashboard.ts @@ -987,6 +987,8 @@ export interface Stats { /** Why a part of the stats is missing: the first plan that includes it (null: none does) and where the plans are. */ export interface StatsLock { analytics: { plan: string | null; upgrade_url: string }; + /** The problem lists, pages, activity and fix prompts (plans.json entitlement `problems`): Free has the totals only. */ + problems?: { plan: string | null; upgrade_url: string; trial_days: number | null; price: string | null }; } /** @@ -1010,6 +1012,26 @@ export function stripAnalytics(s: Stats, lock: StatsLock["analytics"]): Stats { }; } +/** + * The stats as a plan without the `problems` entitlement sees them (Free since 2026-11-12, the owner's "limit free + * fully"): the totals, the previous period's totals and the series stay; every breakdown but visitor analytics' (which + * its own entitlement decides), the latest events and the agent's log are left out, and `locked.problems` says which + * plan shows them. Nothing is deleted: the same request answers in full once the app's plan includes them. Pure. + */ +export function stripProblems(s: Stats, lock: NonNullable): Stats { + return { + ...s, + breakdowns: Object.fromEntries(Object.entries(s.breakdowns).filter(([k]) => k.startsWith("ix_"))), + // the charts keep their bars, one kind each: how many, not which + typeSeries: s.typeSeries.map((b) => ({ bucket: b.bucket, counts: { problems: Object.values(b.counts).reduce((x, y) => x + y, 0) } })), + checkSeries: s.checkSeries.map((b) => ({ bucket: b.bucket, counts: { checks: Object.values(b.counts).reduce((x, y) => x + y, 0) } })), + // the agent's counts stay; what it found and its log (kinds and pages) are Pro's + agent: { ...s.agent, findings: [], log: [] }, + recent: [], + locked: { ...(s.locked ?? { analytics: undefined as never }), problems: lock } as StatsLock, + }; +} + // --------------------------------------------------------------------------------------------- visits and funnels export interface VisitStats { diff --git a/telemetry-worker/src/pages.ts b/telemetry-worker/src/pages.ts index 00a756f0..eddbc6c0 100644 --- a/telemetry-worker/src/pages.ts +++ b/telemetry-worker/src/pages.ts @@ -879,6 +879,7 @@ table.dt td .bdg{margin-left:8px;vertical-align:middle} .ucard svg{margin-top:auto;width:100%;height:110px;display:block} .ucard .ax{display:flex;justify-content:space-between;font:11px var(--mono);color:var(--ink3);margin-top:6px} .uh{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:space-between;margin:48px 0 16px} +.prolock{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:center;justify-content:space-between;border:1px solid var(--accent-b,var(--rule));background:var(--accent-t,transparent);border-radius:12px;padding:16px 18px;margin:0 0 24px}.prolock p{margin:4px 0 0;color:var(--ink2);max-width:640px} .uh h2{font-size:18px;color:var(--ink2)} .uh h2 b{color:var(--ink);font-weight:400} .getc{border:1px dashed var(--rule2);border-radius:6px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));background:var(--s75)} @@ -1198,7 +1199,7 @@ const DASH_JS = ` var d = delta(problemsOf(S), (prev.detections || 0) + (prev.checks || 0)); host.appendChild(stat("Problems found", fmt(problemsOf(S)) + (S.demo ? "+" : ""), S.demo ? "detected in production" : d)); var seenPages = count(S, "page_route"); - host.appendChild(stat("Pages affected", fmt(problemPages(S)), seenPages ? "of " + plural(seenPages, "page", "pages") : "")); + host.appendChild(stat("Pages affected", S.locked && S.locked.problems ? "Pro" : fmt(problemPages(S)), seenPages ? "of " + plural(seenPages, "page", "pages") : "")); if (S.demo) host.appendChild(stat("Fixed live", fmt(T0.interventions), "")); else host.appendChild(stat("Stopped by GenClass", fmt((T0.interventions || 0) + fixed), acting ? (applied ? Math.round(100 * undone / applied) + "% undone" : "") : "Observe mode")); host.appendChild(stat("Model loaded", share(ready, ready + failed), ready ? "average " + (T0.modelLoadAvgMs >= 1000 ? (T0.modelLoadAvgMs / 1000).toFixed(1) + " s" : fmt(T0.modelLoadAvgMs) + " ms") : "")); @@ -1749,6 +1750,21 @@ const DASH_JS = ` var fig = el("figure", "mt-l"), plot = el("div", "plot"); plot.appendChild(g); fig.appendChild(plot); host.appendChild(fig); } + // Free sees the totals only (src/dashboard.ts stripProblems): the problem sections give way to one banner with the + // way to Pro, so an empty list never reads as "no problems" + function proLock(S){ + var lock = S.locked && S.locked.problems, b = document.getElementById("prolock"); + if (b) b.classList.toggle("hidden", !lock); + if (!lock) return; + ["found", "possible-s", "where", "recent-s", "advisor", "adv-s", "fixall2"].forEach(function(id){ var e = document.getElementById(id); if (e) e.classList.add("hidden"); }); + var h = document.getElementById("advn"); if (h && h.parentNode) h.parentNode.classList.add("hidden"); + var m = document.getElementById("prolockmsg"), a = document.getElementById("prolocka"), plan = lock.plan || "Pro"; + if (m) m.textContent = "Every problem, the pages it happens on, what GenClass did about it and a fix prompt for your coding agent come with " + plan + "." + (lock.trial_days ? " " + lock.trial_days + " days free, then " + (lock.price || "the monthly price") + ". Cancel any time." : ""); + if (a) { + if (typeof lock.upgrade_url === "string" && lock.upgrade_url.indexOf("https://") === 0) a.href = lock.upgrade_url; + var t = a.querySelector("span"); if (t) t.textContent = lock.trial_days ? "Start your " + lock.trial_days + "-day free trial" : "Upgrade to " + plan; + } + } var IX_SECTIONS = ["clicks", "ix-sources", "ix-devices", "ix-sessions", "ix-entry", "ix-funnels"]; function visits(S){ var B = S.breakdowns, T0 = S.totals, views = T0.ix_views || 0, on = views > 0 || (B.ix_click || []).length > 0; @@ -2349,12 +2365,14 @@ const DASH_JS = ` document.getElementById("content").classList.toggle("hidden", !live); ["period", "mdcopy", "verdict"].forEach(function(id){ var n = document.getElementById(id); if (n) n.classList.toggle("hidden", !live); }); if (!live) return; - verdict(S); tiles(S); usage(S); advisor(S); fixAll(S); trend(S); possibleTrend(S); cards(S); possibleCards(S); pages(S); acted(S); health(S); stages(S); agentLog(S); loop(S); recent(S); visits(S); advanced(S); + verdict(S); tiles(S); usage(S); advisor(S); fixAll(S); trend(S); possibleTrend(S); cards(S); possibleCards(S); pages(S); acted(S); health(S); stages(S); agentLog(S); loop(S); recent(S); visits(S); advanced(S); proLock(S); var B2 = S.breakdowns, T2 = S.totals; function put(id, t){ var n = document.getElementById(id); if (n) n.textContent = t; } put("c-trend", fmt(problemsOf(S))); - put("c-found", String(problemList(S).length)); - put("c-pages", String(problemPages(S))); + // Free has the totals only: no count that would read as "no problems" next to a view it does not have + var lockedP = !!(S.locked && S.locked.problems); + put("c-found", lockedP ? "" : String(problemList(S).length)); + put("c-pages", lockedP ? "" : String(problemPages(S))); put("c-did", fmt((T2.interventions || 0) + (map(B2.check_fix).applied || 0))); } @@ -2983,6 +3001,7 @@ ${connect}