diff --git a/package-lock.json b/package-lock.json index 0698527c..e7a298b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16819,7 +16819,7 @@ }, "packages/cli": { "name": "genclass", - "version": "0.3.8", + "version": "0.3.9", "license": "Apache-2.0", "bin": { "genclass": "bin/genclass.mjs" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index fd57a796..40366193 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 0.3.9 + +- **`genclass` opens its prompt straight away**: commands and questions, with one line on where this folder's setup + stands. `npx genclass` does the same once the folder is set up (signed in, GenClass in the code, the app in the + account), and walks the setup only before that. `genclass setup` walks it any time, to add or change a part. +- **The installed `genclass` stays current**: a run through npx replaces an older installed copy with its own version, + and the installed copy asks npm once a day and updates itself (`npm install -g genclass@`; a failure says + how by hand and waits a day). After the first install, zsh is told to `rehash` if the window does not see it yet. +- **A coding agent following `status --json` finishes**: every step it can do says `npx genclass setup --yes` (it + said `npx genclass init`, which writes nothing without a terminal, so an agent ran it again and again), and it is + never pointed at a step `setup --yes` cannot do. +- **An app your plan already covers is done**: no third browser page right after paying, nothing to click, and + setup never stops there. Its plan page is in `genclass plan`. +- **Free sees the Pro card once**: later commands say in one line that they come with Pro; the prompt lists what + works on Free (upgrade, profile, plan, status); the card says the price once. +- **Fewer keys in setup**: Enter on a step is its yes, so the Vite settings and the coding agent's files are shown + and written without a second (Y/n) (the runtime's own diff is still asked about). Suggestions in setup show the + first in full and the rest by title, and Enter takes the first choice of the hand-off. +- **Signed out says so**: after `logout`, `genclass` says to sign in again (it said the folder was not set up), and + the price is not quoted to someone signing in again. `plan`, `profile`, `team` and `apps` start the sign-in on a + terminal, as the others do. `login` (in the prompt too) is the setup's own sign-in screen. A sign-in denied twice + ends with how to sign in later. +- **`login` in a folder says what is next in genclass's words**: an app already in the account needs nothing (it + hears which plan it is on), an app without GenClass is pointed at `setup`, which shows its change first; only an + app with GenClass in its code and not in the account is offered `connect`. " is on your Pro plan" is said + once, when the app reaches the account, never before a view. +- One line where there were three: q at Pro's card in setup, and setup inside the prompt after the card was seen + ("Setup goes on with Pro"); setup stopped inside the prompt says to type setup. On Free, `plan` ends with what Pro + adds, and neither `status` nor `setup` is followed by the Free line. `events` names each kind as `problems` does. + A long Waiting line wraps instead of being cut; a step that does not apply has no command in `status --json`. +- The prompt: `y` runs what "Did you mean" named, "show problems" is `problems`, a number no command has says so. + `dashboard` in a web app without GenClass asks which app instead of showing another one. `fix` names each problem + in the dashboard's words too. The git hint is one command a line. +- 58 columns: a wrong option, the sign-in's lines and `--version` (now two lines) fit; a label and a link go on two + lines when one is too wide; setup's notes follow `COLUMNS` without a terminal. +- A team member's `plan --json` has the team's Pro as `plan` (the account's own Free is `own_plan`), with its apps, + history and what it includes. A grant team says "set up by GenClass" everywhere, never that the owner pays. +- A sign-in that did not finish ends with how to get a new code; the sign-in step names the host it opens. + ## 0.3.8 Fixes from a signed-in walk of 0.3.7 against a fresh account, a team and a grant: diff --git a/packages/cli/package.json b/packages/cli/package.json index c52ac8d4..8e205d52 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "genclass", - "version": "0.3.8", + "version": "0.3.9", "description": "GenClass CLI: one command that sets GenClass up, shows your app in the terminal (problems, analytics, events), hands problems to your coding agent, answers questions about your app and tells you what it is missing.", "type": "module", "license": "Apache-2.0", diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index e3c6c0b2..1bece740 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -112,7 +112,8 @@ export async function signIn(o = {}) { export async function choosePlan(state, o = {}, deps = {}) { const cli = state.steps.find((s) => s.id === "plan")?.cli; if (!cli || cli.paid) return "skipped"; - return (await (await import("./paywall.mjs")).startPro(cli, state.account?.offer ?? null, o, deps)) ? "done" : "quit"; + // seen once already in this process (the prompt): one line, not the card again + return (await (await import("./paywall.mjs")).startPro(cli, state.account?.offer ?? null, { ...o, again: "Setup goes on with Pro." }, deps)) ? "done" : "quit"; } /** This app's plan page, in the walk (state.mjs, step "app-plan"): projectPlan with the step's offer. */ @@ -167,6 +168,16 @@ export async function planNow(o = {}, deps = {}, id = null) { return "locked"; } if (o.json) return offer.current ? "covered" : "open"; + // covered by the account's plan: it has its plan, so nothing opens and nothing waits (its page is in `genclass plan`). + // Said once, right after the app reached the account (o.announce: login, connect, init, apps new), never before + // a view (the 0.3.9 user stories: four times in one session) + if (offer.current && !offer.needs) { + if (o.announce) { + out(); + note(`${clean(app.name, 40)} is on your ${clean(offer.current, 20)} plan.`); + } + return "covered"; + } if (o.yes || !interactive()) { out(); // an app the account's plan covers has a plan: it goes on with it once, on its page @@ -266,7 +277,7 @@ async function waitForPlan(offer, o = {}, deps = {}) { if (got === "kept") sp.stop("ok", `${app} goes on with ${clean(offer.current, 20)}.`); else if (got === "paid") sp.stop("ok", `${offer.plan.name} is active${app ? ` for ${app}` : ""}. Thank you.`); else if (got === "free") sp.stop("ok", `${app} is on ${offer.free.name} for now (limited).`); - else sp.stop("warn", app && !offer.needs ? "No choice yet." : `No payment yet. ${offer.plan.name} starts once it is started on Stripe.`); + else sp.stop("warn", app && !offer.needs ? "No choice yet." : `No payment yet: ${clean(offer.plan.name, 20)} is not started.`); return got; } @@ -364,7 +375,9 @@ export async function addViteSettings(state, o = {}) { note('Without them "vite build" fails.'); out(); printChange(plan.change); - if (!o.yes) { + // in the walk (o.confirmed) the step's Enter was the yes: adding GenClass, whose build fails without these two + // lines, or the step "Add two Vite settings" itself (the 0.3.9 user stories: one yes per step, not two) + if (!o.yes && !o.confirmed) { if (!interactive()) { note(`Not a terminal: run ${c.cyan("npx genclass setup --yes")} to write them.`); return "skipped"; @@ -461,7 +474,10 @@ export async function addWorkflow(state, o = {}) { } good(`Wrote ${c.bold(WORKFLOW_PATH)} ${c.gray(`(new file, ${text.split("\n").length - 1} lines)`)}`); note("It is not committed. When you are ready:"); - out(` ${c.cyan(`git add ${WORKFLOW_PATH} && git commit -m "Add GenClass to pull requests" && git push`)}`); + // one command a line, so each fits a narrow terminal and can be copied alone + out(` ${c.cyan(`git add ${WORKFLOW_PATH}`)}`); + out(` ${c.cyan('git commit -m "Add GenClass to pull requests"')}`); + out(` ${c.cyan("git push")}`); return "done"; } @@ -616,12 +632,15 @@ export async function connectAgent(state, o = {}) { out(` ${c.cyan(`"genclass": ${JSON.stringify(mcpServer())}`)}`); return "skipped"; } - // what is written is shown and asked first, as the runtime step's change is (in the walk too); --yes writes it + // what is written is shown first; it is asked for on its own, and in the walk the step's Enter was the yes (its + // sentence says what it adds, as the workflow step's does); --yes writes it if (!o.yes) { out(); printChange(plan.change); - out(` ${c.gray(`And ${SKILL_PATH}: how the agent uses GenClass's tools (a file you changed is left alone).`)}`); + note(`And ${SKILL_PATH}: how the agent uses GenClass's tools (a file you changed is left alone).`); out(); + } + if (!o.yes && !o.confirmed) { if (!interactive()) { note(`Not a terminal: run ${c.cyan("npx genclass setup --yes")} to write it.`); return "skipped"; diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index 2e67c0b7..a0a6870c 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -16,7 +16,7 @@ import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; import { openBrowser, planNow, signIn } from "./actions.mjs"; import { fetchApp, signInIsOld } from "./api.mjs"; import { planName, readState } from "./state.mjs"; -import { ago, bad, clean, header, note, proBox, PROMPT, typed, visible, warn, width, wrap } from "./tui.mjs"; +import { ago, bad, clean, header, hint, linkLine, 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 = { @@ -166,7 +166,7 @@ export function render(app, state, o = {}) { const sg = state?.steps?.find((s) => s.id === "suggestions"); if (sg) part("Suggestions", sg.detail ? (sg.state === "done" ? clean(sg.detail, 60) : `${clean(sg.detail, 60)}${c.gray(" · npx genclass suggest")}`) : c.gray("not run yet: npx genclass suggest")); out(); - if (app.dashboardUrl) out(` ${c.gray("In the browser")} ${c.cyan(app.dashboardUrl)}`); + if (app.dashboardUrl) linkLine("In the browser", app.dashboardUrl); if (!all) out(` ${c.gray("Every row:")} ${c.cyan("npx genclass dashboard --all")}`); out(); } @@ -234,12 +234,15 @@ export async function pickApp(o = {}, deps = {}, name = "dashboard") { const projects = state.projects; // which app: --app by id or by name, else this folder's, else the account's only one const byId = o.app ? projects.filter((p) => p.id === o.app) : []; - const wanted = byId.length ? byId : o.app ? projects.filter((p) => p.name.toLowerCase() === String(o.app).toLowerCase()) : state.app.token ? projects.filter((p) => p.token === state.app.token) : projects.length === 1 ? projects : []; + // a web app here without GenClass is not the account's only app: on a terminal it is asked, not shown as if it + // were this one (the 0.3.9 user stories: a second folder showed the first app's numbers) + const unsetHere = !state.app.token && state.app.isApp && !o.json && !o.yes && interactive(); + const wanted = byId.length ? byId : o.app ? projects.filter((p) => p.name.toLowerCase() === String(o.app).toLowerCase()) : state.app.token ? projects.filter((p) => p.token === state.app.token) : projects.length === 1 && !unsetHere ? projects : []; // on a terminal: the apps as numbered choices, and the number picks one if (wanted.length !== 1 && projects.length && !o.json && !o.yes && interactive()) { const list = (wanted.length > 1 ? wanted : projects).slice(0, 30); header({ app: state.app.name, framework: state.app.framework, account: state.account?.label, plan: planName(state), host: state.host }); - note(wanted.length > 1 ? `More than one app is named "${clean(o.app, 40)}". Which one?` : o.app ? `No app named "${clean(o.app, 40)}". Which one?` : state.app.token ? "This folder's app is not in your account yet. Which app?" : "Which app?"); + note(wanted.length > 1 ? `More than one app is named "${clean(o.app, 40)}". Which one?` : o.app ? `No app named "${clean(o.app, 40)}". Which one?` : state.app.token ? "This folder's app is not in your account yet. Which app?" : unsetHere ? `GenClass is not in this folder's app yet: ${hint("setup")} to add it. Which app do you want to see?` : "Which app?"); list.forEach((p, i) => out(` ${c.gray(String(i + 1).padStart(2))} ${c.bold(clean(appLabel(p, projects), 34))}${p.last_event ? c.gray(` last page ${ago(p.last_event, deps.now)}`) : ""}`)); out(); const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("stop")} `); @@ -319,7 +322,7 @@ export async function appView(o = {}, deps = {}, view) { if (app.reauth) warn(`This sign-in is from before the command could show dashboards. Run ${c.cyan("npx genclass login")} once, then ${c.cyan(`npx genclass ${view.name}`)}.`); else if (app.missing) note("This genclass.dev cannot show a dashboard in the terminal yet."); else bad(`Could not read the dashboard: ${clean(app.reason, 200)}`); - if (target.dashboard_url) out(` ${c.gray("In the browser")} ${c.cyan(target.dashboard_url)}`); + if (target.dashboard_url) linkLine("In the browser", target.dashboard_url); else out(` ${c.gray("Your apps")} ${c.cyan(hostUrl("/account"))}`); out(); } diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index fa8c23f6..e10671db 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -26,8 +26,8 @@ import { width, wrap } from "./tui.mjs"; /** The help's sections: [heading, [command, what it does]...]; usage() lays them out for the terminal's width. */ const HELP = [ [null, [ - ["npx genclass", "where this project stands; on a terminal it walks you through the next step, then stays open for commands and questions"], - ["npx genclass setup", "the guided setup (--yes: no questions, for a coding agent)"], + ["npx genclass", "the command's prompt, for commands and questions (the first time in a folder, the setup first)"], + ["npx genclass setup", "the guided setup, any time, to add or change a part (--yes: no questions, for a coding agent)"], ["npx genclass status", "what is done, what is next (--all: every part; --json)"], ]], ["See", [ @@ -156,7 +156,7 @@ export async function run(argv, ctx = {}) { const po = { cwd: i >= 0 ? args[i + 1] : undefined, yes: args.includes("--yes") || args.includes("-y"), json: args.includes("--json"), noBrowser: args.includes("--no-browser") }; const thenPlan = async (code) => { if (code !== 0) return code; - await (await import("./actions.mjs")).planNow(po).catch(() => "none"); + await (await import("./actions.mjs")).planNow({ ...po, announce: true }).catch(() => "none"); return code; }; if (["logout", "whoami"].includes(cmd)) return (await import("../../runtime/bin/lib/login.mjs")).run(cmd, args); @@ -165,10 +165,14 @@ export async function run(argv, ctx = {}) { // to the account (the runtime login's own question) only once Pro is on, so nothing is written before Pro's card const login = await import("../../runtime/bin/lib/login.mjs"); const noConnect = args.includes("--no-connect"); - const code = await login.run(cmd, noConnect || asksHelp ? args : [...args, "--no-connect"]); + // on a terminal the sign-in is the setup's own step, so it looks like the rest of genclass (the 0.3.9 user + // stories: typed in the prompt it was the runtime CLI's screen, which says "Ctrl-C to cancel"); other options + // (--json, --host, ...) go to the runtime CLI's login + const plain = args.every((a, i) => ["--cwd", "--no-connect", "--no-browser"].includes(a) || args[i - 1] === "--cwd"); + const code = !asksHelp && plain && interactive() ? await ownLogin(po) : await login.run(cmd, noConnect || asksHelp ? args : [...args, "--no-connect"]); if (code !== 0 || asksHelp) return code; if (!(await (await import("./paywall.mjs")).afterLogin(po).catch(() => true))) return code; - return thenPlan(noConnect ? code : await login.offerConnect(args).catch(() => 1)); + return thenPlan(noConnect ? code : await afterSignIn(po, args, login).catch(() => 1)); } if (cmd === "remove") return (await import("./remove.mjs")).remove(args); if (["connect", "init", "scan", "audit"].includes(cmd)) { @@ -209,7 +213,8 @@ export async function run(argv, ctx = {}) { return 2; } if (o.version || cmd === "version") { - out(`genclass ${BUILD.version} (installs @genclass/runtime ${BUILD.runtimeVersion}, @genclass/mcp ${BUILD.mcpVersion})`); + out(`genclass ${BUILD.version}`); + out(`installs @genclass/runtime ${BUILD.runtimeVersion}, @genclass/mcp ${BUILD.mcpVersion}`); return 0; } if (o.help || cmd === "help") { @@ -293,13 +298,84 @@ export async function run(argv, ctx = {}) { if (cmd === "setup" || cmd === null) { // on a terminal the command stays open when the walk ends: a prompt for commands and questions (home.mjs) const home = async (ho) => (await import("./home.mjs")).home(ho, { runCommand: (a) => run(a, { nested: true }), nearest, afterCommand: async () => (await import("./manage.mjs")).freeLine({ cwd: ho.cwd }) }); - // `genclass` alone works from the first run on (home.mjs installShortName) + // `genclass` alone works from the first run on, and stays current (home.mjs installShortName) if (!ctx.nested && !o.yes && !o.json) await (await import("./home.mjs")).installShortName(); + // `genclass` alone opens the command's prompt straight away (the owner's call, 2026-11-13: "genclass by itself + // directly takes you to the genclass cli"); `npx genclass` does too once this folder is set up, and walks the + // setup only before that. The walk itself is `genclass setup`, to change something + if (cmd === null && !ctx.nested && !o.yes && !o.json && interactive()) { + const { readState } = await import("./state.mjs"); + const state = await readState({ cwd: o.cwd }).catch(() => null); + const ready = !!state?.signedIn && !!state?.app?.token; + if (ready || !(await import("./name.mjs")).viaNpx()) { + if (state) (await import("./home.mjs")).opening(state); + // signed in without Pro: the prompt's short list is what works on Free + const free = !!state?.account?.cli && !state.account.cli.paid; + return home({ ...o, cwd: state?.cwd ?? o.cwd, app: state?.app?.name ?? "", free }); + } + } return setup({ ...o, ...(ctx.nested ? { home: false } : {}) }, { runSuggest, home }); } return 2; } +/** + * After `genclass login` with Pro on, on a terminal: what this folder's app needs next, in this command's words. An + * app already in the account needs nothing; an app without GenClass (or without its Vite settings) is pointed at + * setup, which shows its change first; only an app with GenClass in its code but not in the account is offered the + * runtime CLI's connect (the 0.3.9 user stories: its question ran its own picker and init in a folder without + * GenClass, and was asked for an app already connected). With --yes, or without a terminal, it is connect's as before. + */ +async function afterSignIn(po, args, login) { + if (po.yes || !interactive()) return login.offerConnect(args); + let state = null; + try { + state = await (await import("./state.mjs")).readState({ cwd: po.cwd }); + } catch { + state = null; + } + if (!state?.signedIn) return login.offerConnect(args); + const rt = state.steps.find((x) => x.id === "runtime"); + if (!state.app.isApp || !rt || rt.state === "done") return 0; + if (rt.run === "npx genclass connect") return login.offerConnect(args); + const { hint, note } = await import("./tui.mjs"); + out(); + note(`Next: ${hint("setup")} to ${rt.vite ? "add the two Vite settings this app needs" : "add GenClass to this app"}. It shows the change first.`); + return 0; +} + +/** + * `genclass login` on a terminal: the header, then the setup's sign-in step (actions.mjs signIn: the link, the code, + * the browser, Enter stops the wait). Already signed in: says so, and how to switch. Returns the exit code. + */ +async function ownLogin(po) { + const { readCredentialsFor } = await import("../../runtime/bin/lib/account.mjs"); + const { accountLabel, inspectProject } = await import("../../runtime/bin/lib/init.mjs"); + const { fetchOverview } = await import("./api.mjs"); + const { bad, header, note } = await import("./tui.mjs"); + const h = hostConfig(); + let project = null; + try { + project = inspectProject({ cwd: po.cwd }).project; + } catch { + project = null; + } + const saved = readCredentialsFor().creds; + const me = saved ? await fetchOverview(saved) : null; + header({ app: project?.name ?? null, framework: project?.label ?? null, account: me?.ok ? accountLabel(me.account) : null, host: h.overridden && !h.error ? h.host : null }); + if (me?.ok) { + note(`Already signed in as ${accountLabel(me.account)}. To switch accounts: logout, then login.`); + return 0; + } + if (me && me.status !== 401) { + bad(`Could not reach genclass.dev (${me.reason}).`); + return 1; + } + if (me) note("The saved sign-in no longer works: signing in again."); + const r = await (await import("./actions.mjs")).signIn(po); + return r === "done" ? 0 : 1; +} + /** init's options that leave what it does at the telemetry switch: where, whether to ask, and what not to run. */ const TOGGLE_WITH = new Set(["--yes", "-y", "--dry-run", "--no-install", "--no-network"]); diff --git a/packages/cli/src/fix.mjs b/packages/cli/src/fix.mjs index e6d0074c..3a205f38 100644 --- a/packages/cli/src/fix.mjs +++ b/packages/cli/src/fix.mjs @@ -17,7 +17,7 @@ import { spawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; import { delimiter, join } from "node:path"; import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; -import { appView, n, RANGE_WORDS } from "./app.mjs"; +import { appView, KINDS, n, RANGE_WORDS } from "./app.mjs"; import { fixAllPrompt, fixPrompt, infoOf, problemList } from "./kinds.mjs"; import { agentSetup } from "./views.mjs"; import { clean, good, note, PROMPT, typed, warn, width, wrap } from "./tui.mjs"; @@ -107,7 +107,10 @@ function listProblems(list, shown) { const info = infoOf(x); out(` ${c.cyan(String(i + 1).padStart(wNum))} ${c.bold(clean(info.name, 40))} ${c.gray(`${n(x.count)} time${x.count === 1 ? "" : "s"}${x.check ? ", visual check" : ""}`)}`); const pages = x.where.slice(0, 3).map((w) => clean(w.route, 40)).join(", "); - if (pages) for (const l of wrap(`on ${pages}`, width() - 2 - 6 - wNum)) out(` ${" ".repeat(wNum + 2)}${c.gray(l)}`); + // the dashboard's words for the same kind, so the two screens name it the same way (the 0.3.9 user stories) + const plain = KINDS[x.key]; + const under = [plain, pages && `on ${pages}`].filter(Boolean).join(", "); + if (under) for (const l of wrap(under, width() - 2 - 6 - wNum)) out(` ${" ".repeat(wNum + 2)}${c.gray(l)}`); }); if (list.length > shown) out(` ${c.gray(`and ${n(list.length - shown)} more: add --all`)}`); } diff --git a/packages/cli/src/home.mjs b/packages/cli/src/home.mjs index 272c4c68..c70bc171 100644 --- a/packages/cli/src/home.mjs +++ b/packages/cli/src/home.mjs @@ -1,5 +1,6 @@ -// The command's home: what `genclass` is on a terminal once the setup is walked. It stays open. Type a command, or a -// question about your app, as often as you like; q leaves. +// The command's home: what `genclass` typed alone opens, straight away, and what `npx genclass` opens once the folder +// is set up (before that it walks the setup first; `genclass setup` walks it any time). It stays open. Type a +// command, or a question about your app, as often as you like; q leaves. // // Invariants: // - Everything here is also a command of its own (`genclass dashboard`, `genclass analytics`, ...): the home only @@ -14,8 +15,9 @@ import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { BUILD } from "./assets.mjs"; -import { installGlobally, isShort, readPrefs, savePrefs, shortNameWorks, useShortName } from "./name.mjs"; -import { inHome, note, PROMPT, rule, spinner, typed, warn } from "./tui.mjs"; +import { installedVersion, installGlobally, isShort, latestOnNpm, newer, readPrefs, savePrefs, shortNameWorks, useShortName, viaNpx } from "./name.mjs"; +import { planName } from "./state.mjs"; +import { header, inHome, note, PROMPT, rule, spinner, typed, warn } from "./tui.mjs"; /** The commands of the home, in the order they are listed: [what is typed, what it is, shown in the short list]. */ export const COMMANDS = [ @@ -46,6 +48,17 @@ export const COMMANDS = [ const NAMES = [...COMMANDS.map(([n]) => n.split(" ")[0]), "logout", "whoami", "version", "connect", "init", "audit"]; +/** The short list on Free (the account has no Pro): the commands that work there, numbered on their own. */ +export const FREE_MENU = [ + ["upgrade", "start Pro: its free trial first"], + ["profile", "your account and plan"], + ["plan", "your plan and its limits"], + ["status", "where the setup stands"], +]; + +/** Words in front of a command that change nothing ("show problems", "open dashboard"). */ +const VERBS = new Set(["show", "open", "see", "run", "view", "go", "list"]); + /** A typed line as arguments: spaces separate, single or double quotes keep words together. */ export function words(line) { const out = []; @@ -55,8 +68,11 @@ export function words(line) { return out; } -/** What a typed line is: { quit }, { help }, { command: argv }, { question: text } or { unknown: word }. */ -export function read(line, known = NAMES) { +/** + * What a typed line is: { quit }, { help }, { command: argv }, { question: text } or { unknown: word } ({ number } + * too for a number no command has). `numbered`: the list the numbers are of (the short one on Free). + */ +export function read(line, known = NAMES, numbered = COMMANDS) { const text = String(line ?? "").trim(); if (!text) return { help: true }; let argv = words(text); @@ -67,20 +83,39 @@ export function read(line, known = NAMES) { const head = argv[0].toLowerCase(); if (["q", "quit", "exit", "bye"].includes(head)) return { quit: true }; // a number is the command listed with it - if (/^\d{1,2}$/.test(head) && argv.length === 1) { - const cmd = COMMANDS[Number(head) - 1]; - return cmd ? { command: [cmd[0].split(" ")[0]] } : { unknown: head }; + if (/^\d+$/.test(head) && argv.length === 1) { + const cmd = numbered[Number(head) - 1]; + return cmd ? { command: [cmd[0].split(" ")[0]] } : { unknown: head, number: true }; } if (head === "help" || head === "?" || head === "commands") return { help: true, all: true }; if (known.includes(head)) return { command: [head, ...argv.slice(1)] }; + // "show problems": a verb in front of a command is that command + if (VERBS.has(head) && argv[1] && known.includes(argv[1].toLowerCase())) return { command: [argv[1].toLowerCase(), ...argv.slice(2)] }; // a sentence, or anything ending in a question mark, is a question about the app if (argv.length >= 3 || /\?\s*$/.test(text)) return { question: text }; return { unknown: argv[0] }; } -/** The list of commands: the short one, or every command. */ -export function menu(all = false) { +/** + * The list of commands: the short one, or every command. On Free (`free`) the short one is FREE_MENU, the commands + * that work without Pro, and every command is listed by name only. + */ +export function menu(all = false, free = false) { out(); + if (free) { + out(` ${c.bold("What next?")} ${c.gray(all ? "Every command:" : "A number or a command.")}`); + if (all) { + const names = COMMANDS.map(([n]) => n); + for (let i = 0; i < names.length; i += 4) out(` ${c.cyan(names.slice(i, i + 4).map((n) => n.padEnd(13)).join(""))}`); + note("All but upgrade, profile, plan, status, login and remove come with Pro."); + return; + } + const pad = Math.max(...FREE_MENU.map(([n]) => n.length)) + 2; + FREE_MENU.forEach(([name, what], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${c.cyan(name.padEnd(pad))}${c.gray(what)}`)); + out(` ${c.cyan("help".padEnd(pad))}${c.gray("every command")} ${c.cyan("q")} ${c.gray("leave")}`); + note("You are on Free: the other commands come with Pro."); + return; + } out(` ${c.bold("What next?")} ${c.gray("A number, a command, or a question.")}`); const list = COMMANDS.map((x, i) => [...x, i + 1]).filter(([, , short]) => all || short); const pad = Math.max(...list.map(([n]) => n.length)) + 2; @@ -98,16 +133,44 @@ const DAY = 24 * 60 * 60_000; */ export async function installShortName(deps = {}) { if (isShort() || process.env.CI || process.env.GENCLASS_NO_INSTALL || !(deps.interactive ?? interactive)()) return "skipped"; + const p = readPrefs(); + const now = deps.now ?? Date.now(); + const install = deps.install ?? installGlobally; if ((deps.works ?? shortNameWorks)()) { + // the installed command stays current (the 0.3.7 walk: a Mac stayed on its first version): a run through npx, + // which runs the newest, replaces an older installed copy; the installed copy asks npm once a day + const have = (deps.installedVersion ?? installedVersion)(); + const npx = (deps.viaNpx ?? viaNpx)(); + let want = null; + if (have && npx) want = newer(BUILD.version, have) ? BUILD.version : null; + else if (have && !(p.updateCheckedAt && now - Date.parse(p.updateCheckedAt) < DAY)) { + savePrefs({ updateCheckedAt: new Date(now).toISOString() }); + const latest = await (deps.latest ?? latestOnNpm)(); + want = latest && newer(latest, have) ? latest : null; + } + if (want && p.updateFailedAt && now - Date.parse(p.updateFailedAt) < DAY) want = null; + let updated = false; + if (want) { + out(); + const sp = spinner(`Updating the genclass command, ${have} to ${want}`); + const r = await install(want); + if (r.ok) { + savePrefs({ updateFailedAt: null }); + sp.stop("ok", npx ? `Updated to ${want}.` : `Updated to ${want}: the next genclass you type runs it.`); + updated = true; + } else { + savePrefs({ updateFailedAt: new Date(now).toISOString() }); + sp.stop("warn", `Could not update the genclass command: it stays ${have}.`); + note("By hand: npm install -g genclass@latest"); + } + } useShortName(); - return "skipped"; + return updated ? "updated" : "skipped"; } - const p = readPrefs(); - const now = deps.now ?? Date.now(); if (p.installFailedAt && now - Date.parse(p.installFailedAt) < DAY) return "skipped"; out(); const sp = spinner("Adding the genclass command to your terminal"); - const r = await (deps.install ?? installGlobally)(BUILD.version); + const r = await install(BUILD.version); if (!r.ok) { savePrefs({ installFailedAt: new Date(now).toISOString() }); sp.stop("warn", "Could not add the genclass command. npx genclass keeps working."); @@ -119,11 +182,31 @@ export async function installShortName(deps = {}) { if ((deps.works ?? shortNameWorks)()) { useShortName(); sp.stop("ok", "Added: type genclass from now on."); + // zsh remembers where commands are: a window that was open before the install may not see the new one yet + if (/zsh$/.test(process.env.SHELL ?? "")) note("If this window says command not found, run rehash once, or open a new window."); } else sp.stop("ok", "Added. Open a new terminal window and type genclass."); note("npm install -g genclass ran. npm uninstall -g genclass takes it out."); return "installed"; } +/** + * Where the command stands in this folder, above its prompt when `genclass` opens straight on it: the app, the account + * and its plan, and what setup has left (or that setup has not run here). Nothing is asked or done. + */ +export function opening(state) { + header({ app: state.app?.name, framework: state.app?.framework, account: state.account?.label, plan: planName(state), host: state.host }); + const left = state.steps.filter((x) => x.state !== "done" && x.state !== "optional" && x.blocking !== false && x.state !== "waiting"); + // signed out (logout, an expired session) in a folder that is set up: the app is still set up, only the sign-in is + // missing (the 0.3.9 user stories: after logout it said "not set up") + // on Free the next thing is Pro, whatever the folder (setup answers only that it goes on with Pro) + if (state.signedIn && state.account?.cli && !state.account.cli.paid) note(`You are on Free. Type upgrade to start ${state.account.cli.trialDays ? `your ${state.account.cli.trialDays}-day free trial of ` : ""}Pro: setup and the other commands come with it.`); + else if (!state.signedIn && state.app?.token) note(state.sessionProblem === "expired" ? "Your sign-in expired. Type login to sign in again." : "Not signed in. Type login to sign in again."); + else if (!state.signedIn) note("Not signed in, and GenClass is not in this app yet. Type setup: it starts with the sign-in."); + else if (!state.app?.token) note("GenClass is not set up in this folder yet. Type setup to do it, one step at a time."); + else if (left.length) note(`Set up. ${left.length} more step${left.length === 1 ? "" : "s"} you can add (${left.map((x) => x.title).join(", ")}): type setup.`); + else note("Everything is set up. Type setup any time to change something."); +} + /** * The home. `deps.runCommand(argv)` runs one command and resolves its exit code (cli.mjs run); `deps.ask(text)` * answers a question. Returns 0. @@ -132,21 +215,34 @@ export async function installShortName(deps = {}) { export async function home(o = {}, deps = {}) { if (!interactive()) return 0; await installShortName(deps); - menu(); + // on Free the short list is what works there; once Pro is on (upgrade, here), the whole list + let free = !!o.free; + const pro = async () => (await import("./paywall.mjs")).proSeen(); + menu(false, free); inHome(o.app ?? ""); + // the command a "Did you mean" named: y (or yes) runs it + let meant = null; for (;;) { out(); const line = await typed(` ${PROMPT()} `); if (line === null) break; - const got = read(line); + const yes = meant && /^\s*(y|yes)\s*$/i.test(line); + const got = yes ? { command: [meant] } : read(line, NAMES, free ? FREE_MENU : COMMANDS); + meant = null; if (got.quit) break; if (got.help) { - menu(!!got.all); + menu(!!got.all, free); continue; } if (got.unknown) { + if (got.number) { + warn(`No command has the number ${got.unknown}.`); + note(`The numbers are the ones in the list: ${free ? "1 to " + FREE_MENU.length : "help shows them all"}.`); + continue; + } const near = deps.nearest?.(got.unknown); - warn(`There is no command "${got.unknown}".${near ? ` Did you mean ${c.cyan(near)}?` : ""}`); + meant = near; + warn(`There is no command "${got.unknown}".${near ? ` Did you mean ${c.cyan(near)}? (y runs it)` : ""}`); note("help lists them all."); continue; } @@ -158,14 +254,28 @@ export async function home(o = {}, deps = {}) { } catch (e) { warn(`That did not work: ${e?.message ?? e}`); } - // on Free: one line, every time, with the way up - if (!["upgrade", "profile", "plan", "login", "logout", "remove"].includes(argv[0])) { + // on Free: one line, every time, with the way up (not after the gate's own line for the same thing) + let stopped = false; + try { + const paywall = await import("./paywall.mjs"); + stopped = paywall.wasRefused(); + paywall.resetRefused(); + } catch { + /* nothing to read */ + } + // (not after the commands that say where the plan stands themselves: status and setup show Pro's step) + if (!stopped && !["upgrade", "profile", "plan", "login", "logout", "remove", "status", "setup"].includes(argv[0])) { try { await deps.afterCommand?.(); } catch { /* the line is best effort */ } } + // Pro started here (upgrade, or the card after a command): every command works now + if (free && (await pro().catch(() => false))) { + free = false; + menu(false, false); + } rule(); } inHome(null); diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index cdacd185..89226d38 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -16,13 +16,13 @@ import { resolve } from "node:path"; import { createAccountProject, readCredentialsFor } from "../../runtime/bin/lib/account.mjs"; import { hostUrl } from "../../runtime/bin/lib/host.mjs"; import { c, interactive, out, sym } from "../../runtime/bin/lib/ui.mjs"; -import { checkout, mcpServer, openBrowser, planNow, projectPlan, upgradeOffer } from "./actions.mjs"; +import { checkout, mcpServer, openBrowser, planNow, projectPlan, signIn, upgradeOffer } from "./actions.mjs"; import { changeConnection, createInvite, fetchAccount, fetchApp, fetchOverview, fetchConnections, fetchEvents, makeAgentLink, saveFunnels } from "./api.mjs"; import { appArg, n, pickApp } from "./app.mjs"; import { copyText, onPath } from "./fix.mjs"; import { asking, refused } from "./session.mjs"; import { accountPaid, planName, readState } from "./state.mjs"; -import { ago, agree, bad, clean, good, header, note, planCard, PROMPT, secret, spinner, typed, warn, width, wrap } from "./tui.mjs"; +import { ago, agree, bad, clean, good, header, homeOpen, linkLine, note, planCard, PROMPT, secret, spinner, typed, warn, width, wrap } from "./tui.mjs"; import { agentSetup } from "./views.mjs"; const room = () => width() - 2; @@ -35,9 +35,22 @@ const top = (state, target = null) => header({ app: target?.name ?? state.app.na const day = (iso) => String(iso ?? "").slice(0, 10); /** Signed in, or the lines that say how to. Returns { state, creds } or { code }. */ async function signedIn(o, deps, name) { - const state = await readState({ cwd: o.cwd }, deps); - const { creds } = readCredentialsFor(); + let state = await readState({ cwd: o.cwd }, deps); + let { creds } = readCredentialsFor(); if (state.signedIn && creds) return { state, creds }; + // signed out on a terminal: the sign-in starts here, as it does for the commands behind Pro (paywall.mjs + // requirePro), so every command does the same thing signed out (the 0.3.9 user stories). Not for a server that + // cannot be reached, where a new sign-in would not help + if (!o.json && !o.yes && interactive() && !(state.sessionProblem && state.sessionProblem !== "expired")) { + out(); + note("Sign in to genclass.dev first."); + if ((await (deps.signIn ?? signIn)(o)) === "done") { + state = await readState({ cwd: o.cwd }, deps); + creds = readCredentialsFor().creds; + if (state.signedIn && creds) return { state, creds }; + } + return { code: 1 }; + } if (o.json) out(JSON.stringify({ ok: false, error: "not signed in", run: "npx genclass login" })); else { header({ app: state.app.name, framework: state.app.framework, host: state.host }); @@ -93,7 +106,7 @@ export async function apps(o = {}, deps = {}) { out(` ${c.cyan(made.planUrl)}`); } // its plan is chosen now, on its page (Pro, or Free with a reason) - if (made?.id && !full) await planNow({ cwd: o.cwd, noBrowser: o.noBrowser }, deps, made.id); + if (made?.id && !full) await planNow({ cwd: o.cwd, noBrowser: o.noBrowser, announce: true }, deps, made.id); note("To put GenClass into that app's code, run npx genclass in its folder and pick this app."); } out(); @@ -153,8 +166,13 @@ export async function plan(o = {}, deps = {}) { const command = cli ? { paid: cli.paid, ...(cli.paid && cli.via ? { via: cli.via } : {}), ...(pro ? { checkout_url: pro.checkoutUrl, trial_days: pro.trialDays, price: pro.price } : {}) } : null; // without Pro the command runs no model at all: its runs a day are none here, whatever Free's number on the server const limits = a.ok && pro ? { ...a.limits, suggestionsPerDay: 0 } : a.ok ? a.limits : null; - // a team's member: `plan` is the account's own (Free), `team_plan` the team's Pro its seat is on - out(JSON.stringify(a.ok ? { ok: true, plan: a.plan, access: a.access, team_plan: a.teamPlan ?? null, limits, used: a.used, includes: a.includes, subscription: a.subscription, billing_url: a.billingUrl, offer: offer ?? (pro ? a.offer : null), cli: command, apps } : { ok: true, plan: state.account?.plan ?? null, billing_url: state.account?.billingUrl ?? null, offer, cli: command, partial: a.reauth ? "sign in again for the limits: npx genclass login" : "this genclass.dev says no more" }, null, 2)); + // a team's member: `plan`, its limits and what it includes are the team's Pro its seat is on (what an agent + // reading plan.name should see), `own_plan` the account's own (Free), `team_plan` the team's in full + const member = a.ok && a.teamPlan && a.teamPlan.id !== a.plan.id ? a.teamPlan : null; + const shown = member + ? { plan: { id: member.id, name: member.name, via: "team", source: member.source, owner: member.owner }, limits: { ...limits, apps: member.apps, historyDays: member.historyDays, seats: null }, includes: member.includes ?? a.includes } + : a.ok ? { plan: a.plan, limits, includes: a.includes } : {}; + out(JSON.stringify(a.ok ? { ok: true, plan: shown.plan, ...(member ? { own_plan: a.plan } : {}), access: a.access, team_plan: a.teamPlan ?? null, limits: shown.limits, used: a.used, includes: shown.includes, subscription: a.subscription, billing_url: a.billingUrl, offer: offer ?? (pro ? a.offer : null), cli: command, apps } : { ok: true, plan: state.account?.plan ?? null, billing_url: state.account?.billingUrl ?? null, offer, cli: command, partial: a.reauth ? "sign in again for the limits: npx genclass login" : "this genclass.dev says no more" }, null, 2)); return 0; } top(state); @@ -205,13 +223,13 @@ export async function plan(o = {}, deps = {}) { } } if (pro) { - await (await import("./paywall.mjs")).startPro(pro, state.account?.offer ?? (a.ok ? a.offer : null), { ...o, card: true }, deps); + await (await import("./paywall.mjs")).startPro(pro, state.account?.offer ?? (a.ok ? a.offer : null), { ...o, card: true, again: `${clean(pro.plan, 20)} has every genclass command.` }, deps); out(); return 0; } if (!offer) { out(); - out(` ${c.gray("Billing, in the browser")} ${c.cyan(billingUrl)}`); + linkLine("Billing, in the browser", billingUrl); out(); return 0; } @@ -266,8 +284,8 @@ export async function freeLine(o = {}, deps = {}) { // the genclass command is Pro's: Free is the website's (one app and its totals), and the line says so const [first = "", ...rest] = wrap( cli && !cli.paid - ? `You are on Free${lim ? ` (${lim})` : ""}, on the website. Every genclass command comes with ${name}, ${days ? `${days} days free, then ${price}` : price}. Start it: npx genclass upgrade` - : `You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${name} has everything, ${days ? `${days} days free, then ${price}` : `for ${price}`}. Upgrade now: npx genclass upgrade`, + ? `You are on Free${lim ? ` (${lim})` : ""}, on the website. Every genclass command comes with ${name}${days ? `, ${days} days free` : ""}: ${homeOpen() ? "type upgrade" : "npx genclass upgrade"}` + : `You are on Free, which has limits${lim ? ` (${lim})` : ""}. ${name} has everything, ${days ? `${days} days free, then ${price}` : `for ${price}`}. Upgrade now: ${homeOpen() ? "type upgrade" : "npx genclass upgrade"}`, width() - 6, ); out(); @@ -366,7 +384,7 @@ export async function profile(o = {}, deps = {}) { top(state); out(` ${c.bold("Profile")}`); row("Signed in as", clean(state.account?.label ?? "", 60)); - row("Plan", onFree ? `Free ${c.gray(pro ? "(website only: the genclass command comes with Pro)" : "(limited)")}` : `${c.bold(clean(state.account?.plan?.name ?? "", 20))}${viaTeam ? c.gray(" on your team: a seat its owner pays for") : state.account?.plan?.source === "comp" ? c.gray(" set up by GenClass") : ""}`); + row("Plan", onFree ? `Free ${c.gray(pro ? "(website only: the genclass command comes with Pro)" : "(limited)")}` : `${c.bold(clean(state.account?.plan?.name ?? "", 20))}${viaTeam ? c.gray(state.account?.plan?.source === "comp" ? " on your team: set up by GenClass" : " on your team: a seat its owner pays for") : state.account?.plan?.source === "comp" ? c.gray(" set up by GenClass") : ""}`); if (mine.length) { out(); out(` ${c.bold("Apps and their plans")}`); @@ -417,7 +435,7 @@ export async function team(o = {}, deps = {}) { else { top(state); warn("Invites come from the team's owner: ask them for a link."); - note("Your seat is on their team, and they add seats in Manage billing."); + note(state.account?.plan?.source === "comp" ? "Your seat is on their team's plan, set up by GenClass." : "Your seat is on their team, and they add seats in Manage billing."); out(); } return 1; @@ -462,9 +480,10 @@ export async function team(o = {}, deps = {}) { const tp = a.teamPlan; out(` ${c.bold("Team")} ${c.gray(`${clean(tp.name, 20)}, on ${clean(tp.owner ?? "your team's owner", 50)}'s team`)}`); for (const m of t.memberships) out(` ${clean(m.label, 50)} ${c.gray(`owner${m.active ? "" : ` ${sym.dot} your seat is not active`}`)}`); - out(` ${clean(state.account?.label ?? "you", 50)} ${c.gray("you, a seat the owner pays for")}`); + const granted = tp.source === "comp"; + out(` ${clean(state.account?.label ?? "you", 50)} ${c.gray(granted ? "you, on a plan GenClass set up" : "you, a seat the owner pays for")}`); out(); - note("The owner invites people and adds seats in Manage billing. Apps you add go into the team's account."); + note(granted ? "The owner invites people. Apps you add go into the team's account." : "The owner invites people and adds seats in Manage billing. Apps you add go into the team's account."); out(); return 0; } diff --git a/packages/cli/src/name.mjs b/packages/cli/src/name.mjs index cabfcd1e..cd7fb9ac 100644 --- a/packages/cli/src/name.mjs +++ b/packages/cli/src/name.mjs @@ -12,12 +12,15 @@ // is remembered next to the sign-in. Nothing is installed with --yes, without a terminal, or in CI. import { spawn } from "node:child_process"; -import { accessSync, constants, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { accessSync, constants, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { credentialsPath } from "../../runtime/bin/lib/account.mjs"; -/** Whether typing `genclass` in the person's shell runs this command. */ -export function shortNameWorks(env = process.env, platform = process.platform) { +/** Whether this run was started by npx (a copy npx fetched for this run), not the installed `genclass`. */ +export const viaNpx = (argv1 = process.argv[1] ?? "") => /[\\/]_npx[\\/]/.test(String(argv1)); + +/** The file typing `genclass` in the person's shell runs, or null. */ +function shortNameFile(env = process.env, platform = process.platform) { const names = platform === "win32" ? ["genclass.cmd", "genclass.exe", "genclass.ps1"] : ["genclass"]; for (const dir of String(env.PATH ?? env.Path ?? "").split(platform === "win32" ? ";" : delimiter)) { if (!dir || /[\\/]_npx[\\/]/.test(dir) || /node_modules[\\/]\.bin[\\/]?$/.test(dir)) continue; @@ -26,13 +29,66 @@ export function shortNameWorks(env = process.env, platform = process.platform) { const file = join(dir, n); if (!statSync(file).isFile()) continue; if (platform !== "win32") accessSync(file, constants.X_OK); - return true; + return file; } catch { /* not here */ } } } - return false; + return null; +} + +/** + * The version of the installed `genclass` (the package its command links to), or null when it cannot be told (not + * installed, a Windows shim, a copy that is not the npm package). + */ +export function installedVersion(env = process.env, platform = process.platform) { + const file = shortNameFile(env, platform); + if (!file || platform === "win32") return null; + try { + // /bin/genclass links to /lib/node_modules/genclass/bin/genclass.mjs + const pkg = join(dirname(dirname(realpathSync(file))), "package.json"); + const j = JSON.parse(readFileSync(pkg, "utf8")); + return j?.name === "genclass" && typeof j.version === "string" ? j.version : null; + } catch { + return null; + } +} + +/** a > b for versions like 0.3.9 (a prerelease sorts before its release). */ +export function newer(a, b) { + const parse = (v) => { + const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? "")); + return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] ?? null] : null; + }; + const x = parse(a); + const y = parse(b); + if (!x || !y) return false; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; + if (x[3] === y[3]) return false; + return x[3] === null ? true : y[3] === null ? false : x[3] > y[3]; +} + +/** The newest genclass on npm (`latest`), or null within `timeoutMs` (no network, an error): best effort. */ +export async function latestOnNpm(fetchFn = globalThis.fetch, timeoutMs = 2000) { + if (typeof fetchFn !== "function") return null; + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), timeoutMs); + try { + const r = await fetchFn("https://registry.npmjs.org/genclass/latest", { headers: { accept: "application/json" }, signal: ctl.signal }); + if (!r.ok) return null; + const j = await r.json(); + return typeof j?.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(j.version) ? j.version : null; + } catch { + return null; + } finally { + clearTimeout(t); + } +} + +/** Whether typing `genclass` in the person's shell runs this command. */ +export function shortNameWorks(env = process.env, platform = process.platform) { + return shortNameFile(env, platform) !== null; } /** `text` with every `npx genclass` said the short way. */ @@ -44,6 +100,11 @@ let wrapped = false; /** Whether this run prints `genclass` (true) or `npx genclass`. */ export const isShort = () => short; +/** For tests: this process has not decided yet how the command is typed (what is printed keeps its shortening). */ +export const forgetShortName = () => { + short = false; +}; + /** * From now on everything this process prints says `genclass` (the name works here): the runtime CLI's modules are * told through the name they read, and what is written to the terminal is shortened on its way out. diff --git a/packages/cli/src/paywall.mjs b/packages/cli/src/paywall.mjs index b2f68144..c6e245fa 100644 --- a/packages/cli/src/paywall.mjs +++ b/packages/cli/src/paywall.mjs @@ -21,13 +21,25 @@ import { readCredentials } from "../../runtime/bin/lib/account.mjs"; import { accountLabel } from "../../runtime/bin/lib/init.mjs"; import { c, interactive, out } from "../../runtime/bin/lib/ui.mjs"; import { fetchOverview } from "./api.mjs"; -import { clean, note, planCard, PROMPT, typed, warn } from "./tui.mjs"; +import { clean, homeOpen, note, planCard, PROMPT, typed, warn } from "./tui.mjs"; let seen = false; +// the card was shown in this process (the home): a later command that needs Pro gets one line, not the card again +let cardShown = false; +// the last command was stopped by the gate: the home's after-command line (manage.mjs freeLine) is not said again +let refused = false; /** For tests: forget that Pro was seen in this process. */ export const forgetPro = () => { seen = false; + cardShown = false; + refused = false; +}; + +/** Whether the last gate stopped its command (read by the home after each command, then reset with resetRefused). */ +export const wasRefused = () => refused; +export const resetRefused = () => { + refused = false; }; /** Whether this process has seen the account's Pro (the gate passed, or Pro was started here). */ @@ -114,15 +126,27 @@ export async function startPro(cli, offer, o = {}, deps = {}) { } return false; } + const start = cli.trialDays ? `start your ${cli.trialDays}-day free trial` : `start ${clean(cli.plan, 20)}`; + // seen once in this process: one line from then on (the 0.3.9 user stories: the same card three times in four + // commands). `genclass upgrade` and `plan` (o.card) are asked for it, so they show it again + // `again`: the command's own words for that line (plan: what Pro has; setup: it goes on with Pro) + if (cardShown && (o.short || o.again)) { + out(); + note(`${o.again ?? `That comes with ${clean(cli.plan, 20)}.`} ${homeOpen() ? "Type upgrade" : "Run npx genclass upgrade"} to ${start}.`); + refused = true; + return false; + } + cardShown = true; if (o.account) { out(); note(`For ${clean(o.account, 60)}`); } planCard(card, null, heading); + // the price is on the card once; this says only how paying works note( cli.trialDays - ? `Every genclass command comes with ${clean(cli.plan, 20)}. ${cli.trialDays} days free, then ${clean(card.price, 40)}: your card is asked for on Stripe, and nothing is charged if you cancel before the trial ends.` - : `Every genclass command comes with ${clean(cli.plan, 20)}, ${clean(card.price, 40)}, paid on Stripe.`, + ? `Every genclass command comes with ${clean(cli.plan, 20)}. Your card is asked for on Stripe, and nothing is charged if you cancel before the trial ends.` + : `Every genclass command comes with ${clean(cli.plan, 20)}, paid on Stripe.`, ); out(); const dot = c.gray("·"); @@ -130,7 +154,9 @@ export async function startPro(cli, offer, o = {}, deps = {}) { // only Enter or a yes starts it: n, no, s, exit and q all stop, as q does everywhere else if (a === null || !/^(y|yes)?$/i.test(a.trim())) { out(); - note(proLine(cli)); + note(`Not started. ${homeOpen() ? "Type upgrade" : "Run npx genclass upgrade"} any time to ${start}.`); + // the line above is the way up: the prompt's Free line is not said again after it + refused = true; return false; } const { checkout } = await import("./actions.mjs"); @@ -148,6 +174,7 @@ export async function startPro(cli, offer, o = {}, deps = {}) { * @param {{ fetchFn?: typeof fetch, signIn?: (o: object) => Promise, waitMs?: number, everyMs?: number }} [deps] */ export async function requirePro(o = {}, deps = {}) { + refused = false; if (seen) return 0; let creds = readCredentials(); if (!creds) { @@ -173,11 +200,12 @@ export async function requirePro(o = {}, deps = {}) { if (me.ok && me.cli?.paid) seen = true; return 0; } - if (await startPro(me.cli, me.offer ?? null, { ...o, account: accountLabel(me.account) }, deps)) { + if (await startPro(me.cli, me.offer ?? null, { ...o, account: accountLabel(me.account), short: true }, deps)) { seen = true; out(); return 0; } + refused = true; return 1; } diff --git a/packages/cli/src/session.mjs b/packages/cli/src/session.mjs index 81af3aa1..ed442df4 100644 --- a/packages/cli/src/session.mjs +++ b/packages/cli/src/session.mjs @@ -13,7 +13,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 { needsProJson, proLine } from "./paywall.mjs"; -import { bad, clean, note, proBox, typed, warn } from "./tui.mjs"; +import { bad, clean, linkLine, 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 @@ -64,7 +64,7 @@ export function refused(r, ctx) { 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)}`); + if (ctx.browserUrl && !r.upgradeUrl) linkLine("In the browser", ctx.browserUrl); out(); return 1; } diff --git a/packages/cli/src/setup.mjs b/packages/cli/src/setup.mjs index a30d54c5..ac8ed6ef 100644 --- a/packages/cli/src/setup.mjs +++ b/packages/cli/src/setup.mjs @@ -97,6 +97,10 @@ function closing(state) { const blocked = state.steps.find((s) => s.state === "blocked"); if (blocked) { /* show() has said where to run it */ + } else if (!state.signedIn) { + // the sign-in did not finish (the code expired, the browser said no): the way back is one command + out(` ${c.bold("Not signed in yet.")}`); + note("Run npx genclass again for a new code: setup goes on from the sign-in."); } else if (state.complete) out(` ${c.green("Everything is set up.")}`); else if (waiting.length) { out(` ${c.bold("Nothing more to do here right now.")}`); @@ -105,7 +109,7 @@ function closing(state) { out(); out(` ${c.gray("Day to day")}`); // the commands in one column with room after the longest, so the short name (genclass ...) keeps a gap too - out(` ${c.cyan("npx genclass".padEnd(25))}${c.gray("where things stand")}`); + out(` ${c.cyan("npx genclass".padEnd(25))}${c.gray("the prompt: commands, questions")}`); out(` ${c.cyan("npx genclass suggest".padEnd(25))}${c.gray("what your app is missing")}`); if (state.app.token) out(` ${c.cyan("npx genclass dashboard".padEnd(25))}${c.gray("what it found on your site")}`); out(); @@ -122,7 +126,10 @@ export async function status(o = {}) { const step = state.next; if (step) { showNext(step, !!o.all); - barPara(`${c.gray("Run")} ${c.cyan(step.run ?? "npx genclass setup")}${step.human ? c.gray(` (then: ${step.human})`) : ""}`, (l) => l); + // inside the prompt a command is typed without npx genclass in front + const cmd = step.run ?? "npx genclass setup"; + const typedHere = homeOpen() && cmd.startsWith("npx genclass "); + barPara(`${c.gray(typedHere ? "Type" : "Run")} ${c.cyan(typedHere ? cmd.slice("npx genclass ".length) : cmd)}${step.human ? c.gray(` (then: ${step.human})`) : ""}`, (l) => l); out(); if (!o.all) out(` ${c.gray("Every part:")} ${c.cyan("npx genclass status --all")}`); out(); @@ -196,7 +203,7 @@ async function walk(o, deps, auto) { return deps.home({ ...o, cwd: state.cwd, app: state.app.name ?? "" }); } out(); - note("Stopped. Run npx genclass again any time: it continues from here."); + note(o.home === false ? "Setup stopped here. Type setup to go on with it." : "Stopped. Run npx genclass again any time: it continues from here."); out(); return 0; } @@ -227,8 +234,12 @@ async function walk(o, deps, auto) { result = step.id === "plan" ? await choosePlan(state, so) : await chooseAppPlan(state, so); // nothing after Pro works without it (paywall.mjs), and an app waits for its plan if (result === "quit") { - out(); - if (!auto) note("Stopped. Run npx genclass again any time: it continues from here."); + // Pro's card has said how to start it later (one line is enough: the 0.3.9 user stories had three); the + // plan page's wait said where the page is. Inside the prompt, setup is typed again, not npx genclass + if (!auto && step.id !== "plan") { + out(); + note(o.home === false ? "Setup stopped here. Type setup to go on with it." : "Stopped. Run npx genclass again any time: it continues from here."); + } out(); return auto ? 1 : 0; } @@ -246,8 +257,9 @@ async function walk(o, deps, auto) { } if (result === "failed") { failed = true; - // what went wrong is above, in the step's own words; this says that the walk goes on without it - if (!auto) { + // what went wrong is above, in the step's own words; this says that the walk goes on without it (not for the + // sign-in: every step after it needs it, so "the others go on" would not be true) + if (!auto && step.id !== "account") { out(); warn(`${step.doing} did not finish. The steps that do not need it go on, and npx genclass offers it again next time.`); } @@ -256,7 +268,10 @@ async function walk(o, deps, auto) { // one exception is a sign-in that did not finish (the code expired, the browser said no) while someone is at the // keyboard: everything after it is better with an account, so it is offered once more if (step.id === "account" && result === "failed" && !auto && signInTries++ < 1) note("Enter tries the sign-in again with a new code."); - else skip.push(step.id); + else { + if (step.id === "account" && result === "failed" && !auto) note(`Sign in any time: ${deps.home && o.home !== false ? "type login" : "npx genclass login"}.`); + skip.push(step.id); + } out(); rule(); } diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index 7aaf844d..7838f70f 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -194,7 +194,12 @@ export async function readState(o = {}, deps = {}) { ? `the saved session is for ${otherHost}` : "", // said before anyone signs up: the command is Pro's, with its trial and price (the owner's call, 2026-11-13) - why: "Opens genclass.dev. Then Pro: 3 days free, then $15 / developer / month, with a card on Stripe.", + // the host the sign-in opens (genclass.dev, or GENCLASS_HOST in a test) + // someone signing in again (a saved session, or an app set up in this folder) already has an account and maybe + // Pro: the price is said only to someone new (the 0.3.9 user stories: it was quoted to a payer after logout) + why: creds || st.token + ? `Opens ${host.overridden && !host.error ? new URL(host.host).host : "genclass.dev"} to sign you in again.` + : `Opens ${host.overridden && !host.error ? new URL(host.host).host : "genclass.dev"}. Then Pro: 3 days free, then $15 / developer / month, with a card on Stripe.`, more: `Your account is where your apps, their dashboards and your plan live. New here? It is made on that page. Nothing is typed in the terminal. ${PRO_TERMS} Free stays on the website: one app and its totals.`, run: "npx genclass login", human: "approve the sign-in in your browser", @@ -211,7 +216,7 @@ export async function readState(o = {}, deps = {}) { doing: cli && !cli.paid ? (cli.trialDays ? `Start your ${cli.trialDays}-day free trial` : `Start ${cli.plan}`) : "Pro", state: !me ? (creds && offline !== "expired" ? "optional" : "todo") : !cli ? "optional" : cli.paid ? "done" : "todo", detail: !me - ? creds + ? creds || st.token ? "" : "3 days free, then $15 / developer / month" : cli && !cli.paid @@ -220,7 +225,7 @@ export async function readState(o = {}, deps = {}) { ? "through your team's plan" : cli?.paid && cli.via === "open" ? "open on this genclass.dev" - : me.plan && me.plan.id !== "free" + : me.plan && me.plan.id !== "free" && me.plan.name !== "Pro" ? me.plan.name : "", why: cli && !cli.paid ? `Every genclass command comes with ${cli.plan}${cli.trialDays ? `, ${cli.trialDays} days free to start` : ""}.` : me ? "Every genclass command comes with Pro." : PRO_TERMS, @@ -276,9 +281,10 @@ export async function readState(o = {}, deps = {}) { id: "app-plan", title: "App plan", doing: appOffer?.current ? `Continue with ${appOffer.current} for this app` : "Choose this app's plan", - state: appOffer ? "todo" : "optional", - // an app the account's plan already covers (paid, or GenClass's grant) waits on nothing: its page's Continue is - // offered once, never required, so a setup with --yes or a coding agent still ends complete + // an app the account's plan already covers (paid, in its trial, GenClass's grant) has its plan: the step is done, + // so no page opens and nothing waits (the 0.3.9 user stories: a third browser page right after paying, and a + // setup that stopped there). Its plan page stays one link away, in the detail + state: appOffer ? (appOffer.current && !appOffer.needs ? "done" : "todo") : app?.plan?.source && !app.needsPlan && !app.shared ? "done" : "optional", blocking: !(appOffer?.current && !appOffer.needs), detail: !me || !app?.plan ? (app?.shared ? "the team's owner chooses it" : "") @@ -288,7 +294,7 @@ export async function readState(o = {}, deps = {}) { ? `${appOffer.current ?? "Your plan"} covers no more apps` : `Free covers ${appOffer.free.apps === 1 ? "one app" : `${appOffer.free.apps ?? 1} apps`}: this one needs ${appOffer.plan.name}` : appOffer.current - ? `covered by ${appOffer.current}: continue on its plan page` + ? `covered by ${appOffer.current}` : `${appOffer.plan.name}, ${appOffer.price}` : `${app.plan.name}${app.plan.source === "comp" ? " (set up by GenClass)" : ""}`, why: appOffer?.needs && appOffer.paying @@ -441,16 +447,22 @@ export const planName = (state, token = state?.app?.token) => (state?.projects ? /** The state as plain data for `--json`: what a coding agent reads to know where things stand and what to run. */ /** The command for a step as a coding agent runs it: without a terminal the walk does a step only with --yes. */ -const forAgent = (run) => (run === "npx genclass setup" ? "npx genclass setup --yes" : run); +// init and connect too: without --yes they write nothing, so an agent told "npx genclass init" would read the same +// state and be told it again, for ever (the 0.3.9 user stories); setup --yes does the step the way the walk does it +const forAgent = (run) => (["npx genclass setup", "npx genclass init", "npx genclass connect"].includes(run) ? "npx genclass setup --yes" : run); export function stateJson(s) { + // a step that waits on nothing (an app the plan already covers: its page's Continue is offered, not required) is + // never what a coding agent is told to do next: `setup --yes` cannot click it, and would be run again and again + const agentNext = s.next && s.next.blocking === false ? (s.steps.find((x) => x !== s.next && x.state === "todo" && x.blocking !== false) ?? null) : s.next; return { app: s.app, signed_in: s.signedIn, account: s.account ? { label: s.account.label, plan: s.account.plan } : null, github: s.git.github, - steps: s.steps.map((x) => ({ id: x.id, title: x.title, state: x.state, detail: x.detail, run: x.state === "done" ? null : forAgent(x.run), human: x.state === "done" ? null : x.human })), - next: s.next ? { id: s.next.id, title: s.next.doing, run: forAgent(s.next.run), human: s.next.human, why: s.next.why } : null, + // a step that does not apply here (optional: no git repository, no tests) has nothing for an agent to run + steps: s.steps.map((x) => ({ id: x.id, title: x.title, state: x.blocking === false && x.state !== "done" ? "optional" : x.state, detail: x.detail, run: x.state === "done" || x.state === "optional" || x.blocking === false ? null : forAgent(x.run), human: x.state === "done" || x.state === "optional" ? null : x.human })), + next: agentNext ? { id: agentNext.id, title: agentNext.doing, run: forAgent(agentNext.run), human: agentNext.human, why: agentNext.why } : null, complete: s.complete, }; } diff --git a/packages/cli/src/suggest/index.mjs b/packages/cli/src/suggest/index.mjs index c5bb65b5..97d284f6 100644 --- a/packages/cli/src/suggest/index.mjs +++ b/packages/cli/src/suggest/index.mjs @@ -19,7 +19,7 @@ import { requestPlan } from "../api.mjs"; import { proSeen } from "../paywall.mjs"; import { copyText, findAgents, startAgent } from "../fix.mjs"; import { readNotes, saveNotes } from "../store.mjs"; -import { agree, clean, good, header, note, para, proBox, PROMPT, rule, spinner, typed, visible, warn, width, wrap } from "../tui.mjs"; +import { agree, clean, good, header, hint, 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"; @@ -191,8 +191,10 @@ async function handToAgent(items, url, cwd, o, deps, plan) { choices.forEach(([label], i) => out(` ${c.gray(String(i + 1).padStart(2))} ${label}`)); if (!agents.length) note("No coding agent's command found here (claude, codex, cursor-agent, gemini)."); out(); - const a = await typed(` ${PROMPT()} ${c.gray("a number ·")} ${c.bold("q")} ${c.gray("stop")} `); - const pick = a !== null && /^\d+$/.test(a.trim()) ? choices[Number(a.trim()) - 1] : null; + // Enter is the first choice (the 0.3.9 user stories: Enter did nothing, and said nothing); input that ended is not + const a = await typed(` ${PROMPT()} ${c.bold("Enter")} ${c.gray("for 1 · a number ·")} ${c.bold("q")} ${c.gray("stop")} `); + const t = a === null ? null : a.trim(); + const pick = t === "" ? choices[0] : t !== null && /^\d+$/.test(t) ? choices[Number(t) - 1] : null; if (pick) await pick[1](); } @@ -298,11 +300,13 @@ export async function suggest(o = {}, deps = {}) { 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; + // in the setup's walk only the first is in full, the rest by title (the 0.3.9 user stories: about 70 lines in the + // middle of the setup); `genclass suggest` on its own prints five, --all every one + const fullCount = o.all ? items.length : o.fromSetup ? 1 : 5; 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.`); + note(o.fromSetup ? `The others in full, with a prompt for each: ${hint("suggest")}.` : `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 (plan?.ideas?.length) printIdeas(plan.ideas, o.all); if (plan?.designs?.length) printDesigns(plan.designs); diff --git a/packages/cli/src/tui.mjs b/packages/cli/src/tui.mjs index e71f7752..94a86b4b 100644 --- a/packages/cli/src/tui.mjs +++ b/packages/cli/src/tui.mjs @@ -123,6 +123,15 @@ export function para(text, indent = " ", paint = (s) => s) { for (const l of wrap(text, width() - 2 - indent.length)) out(`${indent}${paint(l)}`); } +/** " Label url": on one line when it fits the window, else the link on its own line under the label. */ +export function linkLine(label, url) { + if (2 + visible(label) + 2 + visible(url) <= width() - 2) out(` ${c.gray(label)} ${c.cyan(url)}`); + else { + out(` ${c.gray(label)}`); + out(` ${c.cyan(url)}`); + } +} + /** A horizontal rule as wide as the text. */ export const rule = () => out(` ${c.gray((unicode ? "─" : "-").repeat(width() - 4))}`); @@ -134,6 +143,9 @@ export const inHome = (app) => { /** Whether the command's home is open (its card was shown once, at the top). */ export const homeOpen = () => homeApp !== null; +/** A command as a hint: "type setup" in the home (homeOpen), else "run npx genclass setup". */ +export const hint = (cmd) => (homeOpen() ? `type ${cmd}` : `run npx genclass ${cmd}`); + /** * The header: the product, this app and its framework on one line, the account and its plan under it. * @param {{ app?: string | null, framework?: string | null, account?: string | null, plan?: string | null, host?: string | null }} h @@ -265,9 +277,11 @@ export function planCard(offer, app, heading = null) { } 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)); + // (the price is on the line above: said once) + if (offer.trialDays) for (const l of wrap(`The first ${offer.trialDays} days are free. Cancel any time.`, room)) row(c.green(l)); row(); - for (const f of offer.features) { + // a feature line that says the trial again is left out: the line above says it + for (const f of offer.trialDays ? offer.features.filter((x) => !/days? free/i.test(x)) : offer.features) { const [first = "", ...rest] = wrap(clean(f, 120), room - 2); row(`${c.green(sym.ok)} ${first}`); for (const l of rest) row(` ${l}`); @@ -344,9 +358,11 @@ export function summary(steps) { const next = steps.find((s) => s.state === "next"); const later = steps.filter((s) => s.state === "todo"); if (done.length) line(MARK.done(), pad("Done"), names(done)); - for (const b of steps.filter((s) => s.state === "blocked")) line(MARK.blocked(), pad(b.title), c.gray(clean(b.detail ?? "", room))); + // a detail that does not fit goes on under itself, never cut short (the 0.3.9 user stories: "checked 14 minutes a…") + const lines = (text) => wrap(text, room); + for (const b of steps.filter((s) => s.state === "blocked")) line(MARK.blocked(), pad(b.title), lines(clean(b.detail ?? "", 200))); if (next) line(MARK.next(), c.bold(pad("Next")), c.bold(clean(next.doing ?? next.title, room))); - for (const w of steps.filter((s) => s.state === "waiting")) line(MARK.waiting(), pad("Waiting"), c.gray(clean(`${w.title}: ${w.detail ?? ""}`, room))); + for (const w of steps.filter((s) => s.state === "waiting")) line(MARK.waiting(), pad("Waiting"), lines(clean(`${w.title}: ${w.detail ?? ""}`, 200))); if (later.length) line(MARK.todo(), c.gray(pad("Later")), names(later)); } diff --git a/packages/cli/src/views.mjs b/packages/cli/src/views.mjs index 8d5da009..43a3ea7b 100644 --- a/packages/cli/src/views.mjs +++ b/packages/cli/src/views.mjs @@ -19,9 +19,9 @@ import { join, resolve } from "node:path"; import { c, out, sym } from "../../runtime/bin/lib/ui.mjs"; import { appView, fit, KINDS, n, RANGE_WORDS, spark } from "./app.mjs"; import { brand } from "./art.mjs"; -import { ACTION_NAMES, checkFixed, checkOf, didAct, markdown } from "./kinds.mjs"; +import { ACTION_NAMES, checkFixed, checkOf, didAct, markdown, TYPES } from "./kinds.mjs"; import { agentConfigs } from "./state.mjs"; -import { ago, bad, clean, good, note, width, wrap } from "./tui.mjs"; +import { ago, bad, clean, good, linkLine, note, width, wrap } from "./tui.mjs"; /** The longest line that fits the window. */ const room = () => width() - 2; @@ -235,7 +235,7 @@ export function renderAnalytics(app, o = {}) { out(` ${c.gray("One part:")} ${c.cyan("npx genclass analytics pages")}`); out(` ${c.gray(`also ${ANALYTICS_SECTIONS.slice(1).join(", ")}`)}`); } - if (app.dashboardUrl) out(` ${c.gray("In the browser")} ${c.cyan(app.dashboardUrl)}`); + if (app.dashboardUrl) linkLine("In the browser", app.dashboardUrl); out(); } @@ -362,14 +362,16 @@ export function renderEvents(app, o = {}) { } const short = (iso) => ago(iso, o.now).replace(/ minutes? ago/, " min ago").replace(/ hours? ago/, " h ago"); const wWhen = Math.max(...rows.map((r) => short(r.at).length)); - const wKind = Math.max(4, Math.min(14, Math.max(...rows.map((r) => plain(r.kind ?? r.trigger ?? "").length)))); + // a kind by the name `problems` and `fix` give it ("Double submits", not "duplicate": the 0.3.9 user stories) + const kindName = (r) => (r.kind && TYPES[r.kind]?.name) || plain(r.kind ?? r.trigger ?? ""); + const wKind = Math.max(4, Math.min(18, Math.max(...rows.map((r) => kindName(r).length)))); // 4 of indent, when, 2, kind, 2, how sure (4), 2, the page const wPage = Math.max(8, room() - 14 - wWhen - wKind); head(`The latest ${rows.length} event${rows.length === 1 ? "" : "s"}`, "Newest first. sure: how confident the model was."); out(` ${c.gray("when".padEnd(wWhen))} ${c.gray("kind".padEnd(wKind))} ${c.gray("sure")} ${c.gray("page")}`); for (const r of rows) { const sure = r.confidence === null ? "" : `${Math.round(r.confidence * 100)}%`; - out(` ${c.gray(short(r.at).padEnd(wWhen))} ${clean(plain(r.kind ?? r.trigger ?? ""), wKind).padEnd(wKind)} ${sure.padStart(4)} ${c.cyan(clean(r.route ?? "", wPage))}`.trimEnd()); + out(` ${c.gray(short(r.at).padEnd(wWhen))} ${clean(kindName(r), wKind).padEnd(wKind)} ${sure.padStart(4)} ${c.cyan(clean(r.route ?? "", wPage))}`.trimEnd()); const did = didAct(r) ? actionWords(r.ran) : null; if (did) out(` ${" ".repeat(wWhen)} ${c.green(`${sym.arrow} ${clean(did, 60)}`)}`); if (all) { diff --git a/packages/cli/test/actions.test.ts b/packages/cli/test/actions.test.ts index 9e73a02d..a313536e 100644 --- a/packages/cli/test/actions.test.ts +++ b/packages/cli/test/actions.test.ts @@ -45,7 +45,10 @@ describe("genclass: the one workflow file (src/assets.mjs, src/actions.mjs workf const out = await printed(async () => expect(await addWorkflow(state, { yes: true })).toBe("done")); expect(readFileSync(join(dir, WORKFLOW_PATH), "utf8")).toBe(WORKFLOW_FILE); expect(out).toContain("new file"); - expect(out).toContain(`git add ${WORKFLOW_PATH} && git commit -m "Add GenClass to pull requests" && git push`); + // one command a line, each short enough for a narrow terminal + expect(out).toContain(`git add ${WORKFLOW_PATH}\n`); + expect(out).toContain('git commit -m "Add GenClass to pull requests"\n'); + expect(out).toContain("git push"); expect(existsSync(join(dir, ".git"))).toBe(false); // a second time the file is there: it is left alone write(dir, { [WORKFLOW_PATH]: "mine\n" }); @@ -249,7 +252,7 @@ describe("genclass: the command line (src/cli.mjs, src/main.mjs)", () => { it("help, the version and an unknown command", async () => { expect(await printed(async () => expect(await run(["--help"])).toBe(0))).toContain(USAGE); - expect(await printed(async () => expect(await run(["version"])).toBe(0))).toBe(`genclass ${BUILD.version} (installs @genclass/runtime ${BUILD.runtimeVersion}, @genclass/mcp ${BUILD.mcpVersion})\n`); + expect(await printed(async () => expect(await run(["version"])).toBe(0))).toBe(`genclass ${BUILD.version}\ninstalls @genclass/runtime ${BUILD.runtimeVersion}, @genclass/mcp ${BUILD.mcpVersion}\n`); expect(await run(["frobnicate"])).toBe(2); // the form that runs without installing, everywhere a command is named expect(USAGE).toContain("npx genclass dashboard"); diff --git a/packages/cli/test/fix.test.ts b/packages/cli/test/fix.test.ts index 731c37a0..4dfe8070 100644 --- a/packages/cli/test/fix.test.ts +++ b/packages/cli/test/fix.test.ts @@ -126,7 +126,8 @@ describe("genclass fix: the command (src/fix.mjs fix)", () => { expect(tooWide(said)).toEqual([]); const flat = plainLines(said).join(" ").replace(/\s+/g, " "); expect(flat).toContain("What GenClass found on the live site"); - expect(flat).toMatch(/1 Double submits 14 times on \/checkout/); + // the dashboard's words under the name, so both screens name it the same way + expect(flat).toMatch(/1 Double submits 14 times the same request went out twice, on \/checkout/); expect(flat).toMatch(/3 Invisible text 6 times, visual check on \/pricing/); expect(flat).toContain("A prompt for stale responses is ready."); expect(flat).toContain("GenClass changes no code: the agent does, with you watching."); diff --git a/packages/cli/test/gate.test.ts b/packages/cli/test/gate.test.ts index 7dd5abe2..d6e61560 100644 --- a/packages/cli/test/gate.test.ts +++ b/packages/cli/test/gate.test.ts @@ -233,8 +233,14 @@ describe("genclass at a terminal: the paywall's question, the plan page's wait, expect(got, answer).toBe(false); expect(said, answer).not.toMatch(/Waiting for the payment|Your browser is opening/); expect(flat(said), answer).toContain("For ada@example.com"); - expect(flat(said), answer).toContain(`start your 3-day free trial at ${CHECKOUT}`); + // stopped: what starts it later, without the price again (it is on the card) + expect(flat(said), answer).toContain("Not started. Run npx genclass upgrade any time to start your 3-day free trial."); + expect(flat(said).match(/\$15/g), answer).toHaveLength(1); } + // the card once per process: a later command that needs Pro gets one line (the gate), upgrade and plan the card + type("q"); + const again = await printed(async () => expect(await startPro(cli, null, { short: true }, { waitMs: 50, everyMs: 10 })).toBe(false)); + expect(flat(again).trim()).toBe("That comes with Pro. Run npx genclass upgrade to start your 3-day free trial."); }); it("the plan page's wait: a Free choice made before it is not its answer; a stopped wait says it once", async () => { @@ -250,18 +256,23 @@ describe("genclass at a terminal: the paywall's question, the plan page's wait, // the account's checkout, stopped: "No payment yet" once, not twice type(""); const pay = await printed(async () => expect(await startPro(cli, null, {}, { fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: LOCKED }) } }).fetchFn, waitMs: 60, everyMs: 10 })).toBe(false)); - expect(flat(pay).match(/starts once it is started on Stripe/g)).toHaveLength(1); + expect(flat(pay).match(/No payment yet/g)).toHaveLength(1); expect(pay).not.toContain("goes on from there"); }); - it("the coding agent's files are shown and asked about in the walk too; no writes them nothing", async () => { + it("the coding agent's files are shown; on their own they are asked about (no writes nothing), in the walk the step's Enter was the yes", async () => { const dir = tempDir(); viteApp(dir); type("n"); - const said = await printed(async () => expect(await connectAgent({ cwd: dir }, { confirmed: true })).toBe("skipped")); + const said = await printed(async () => expect(await connectAgent({ cwd: dir }, {})).toBe("skipped")); expect(said).toContain(".mcp.json"); expect(said).toContain(".claude/skills/genclass/SKILL.md"); expect(existsSync(join(dir, ".mcp.json"))).toBe(false); + // the walk: shown, then written, with no second question + const walked = await printed(async () => expect(await connectAgent({ cwd: dir }, { confirmed: true })).toBe("done")); + expect(walked).toContain(".mcp.json"); + expect(walked).not.toContain("and the skill?"); + expect(existsSync(join(dir, ".mcp.json"))).toBe(true); }); it("taken out and added again: the account's app of the same name is used, not a second one", async () => { diff --git a/packages/cli/test/home.test.ts b/packages/cli/test/home.test.ts index 220306db..f51bf97f 100644 --- a/packages/cli/test/home.test.ts +++ b/packages/cli/test/home.test.ts @@ -1,12 +1,13 @@ // The command's home (src/home.mjs): after the setup the command stays open for commands and questions. And how the // command is typed on this machine (src/name.mjs): `genclass` once it is installed, `npx genclass` until then. +import { BUILD } from "../src/assets.mjs"; import { chmodSync } from "node:fs"; import { join } from "node:path"; import { PassThrough } from "node:stream"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { COMMANDS as ALL, nearest, run } from "../src/cli.mjs"; -import { COMMANDS, home, installShortName, menu, read, words } from "../src/home.mjs"; -import { readPrefs, savePrefs, shorten, shortNameWorks } from "../src/name.mjs"; +import { COMMANDS, FREE_MENU, home, installShortName, menu, read, words } from "../src/home.mjs"; +import { forgetShortName, readPrefs, savePrefs, shorten, shortNameWorks } from "../src/name.mjs"; import { isolate, printed, tempDir, write } from "./helpers.js"; import { plainLines, tooWide } from "./stats.js"; @@ -82,7 +83,7 @@ describe("genclass: how the command is typed here (src/name.mjs)", () => { expect(await installShortName({ interactive: () => true, now: Date.now() + 25 * 60 * 60_000, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("installed"); expect(ran).toHaveLength(2); // already there, no terminal, or CI: nothing runs - expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => true })).toBe("skipped"); + expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => true, installedVersion: () => null })).toBe("skipped"); expect(await installShortName({ interactive: () => false, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("skipped"); process.env.CI = "1"; expect(await installShortName({ interactive: () => true, install: async (v: string) => (ran.push(v), { ok: true, text: "" }), works: () => false })).toBe("skipped"); @@ -92,6 +93,39 @@ describe("genclass: how the command is typed here (src/name.mjs)", () => { else process.env.CI = before; } }); + + it("the installed command stays current: npx replaces an older copy, the installed one asks npm once a day (the 0.3.7 walk)", async () => { + const before = process.env.CI; + delete process.env.CI; + try { + const ran: string[] = []; + const install = async (v: string) => (ran.push(v), { ok: true, text: "" }); + const fresh = () => forgetShortName(); + // npx runs the newest: an older installed copy is replaced by this version + fresh(); + const up = await at58(async () => expect(await installShortName({ interactive: () => true, install, works: () => true, installedVersion: () => "0.3.6", viaNpx: () => true })).toBe("updated")); + expect(ran).toEqual([BUILD.version]); + expect(plainLines(up).join(" ")).toContain(`Updating the genclass command, 0.3.6 to ${BUILD.version}`); + expect(tooWide(up)).toEqual([]); + // the same version installed: nothing to do, and nothing asked of npm + let asked = 0; + const latest = async () => (asked++, "99.0.0"); + fresh(); + expect(await installShortName({ interactive: () => true, install, works: () => true, installedVersion: () => BUILD.version, viaNpx: () => true, latest })).toBe("skipped"); + expect(asked).toBe(0); + // the installed copy asks npm, at most once a day, and updates to what npm has + const now = Date.now() + 3 * 24 * 60 * 60_000; + fresh(); + expect(await installShortName({ interactive: () => true, install, works: () => true, installedVersion: () => BUILD.version, viaNpx: () => false, latest, now })).toBe("updated"); + expect(ran).toEqual([BUILD.version, "99.0.0"]); + fresh(); + expect(await installShortName({ interactive: () => true, install, works: () => true, installedVersion: () => BUILD.version, viaNpx: () => false, latest, now: now + 60_000 })).toBe("skipped"); + expect(asked).toBe(1); + } finally { + if (before === undefined) delete process.env.CI; + else process.env.CI = before; + } + }); }); describe("genclass: the home (src/home.mjs)", () => { @@ -115,7 +149,11 @@ describe("genclass: the home (src/home.mjs)", () => { // a number is the command listed with it expect(read("1")).toEqual({ command: ["dashboard"] }); expect(read(String(COMMANDS.findIndex(([n]) => n === "profile") + 1))).toEqual({ command: ["profile"] }); - expect(read("99")).toEqual({ unknown: "99" }); + expect(read("99")).toEqual({ unknown: "99", number: true }); + // a verb in front of a command is that command; on Free the numbers are the short list's + expect(read("show problems")).toEqual({ command: ["problems"] }); + expect(read("open dashboard")).toEqual({ command: ["dashboard"] }); + expect(read("1", undefined, FREE_MENU)).toEqual({ command: ["upgrade"] }); expect(read("npx genclass")).toEqual({ help: true }); // every command the command line has, including the runtime's own, is a command here and never a question for (const cmd of ["connect", "init", "audit"]) expect(read(`${cmd} --name shop now`)).toEqual({ command: [cmd, "--name", "shop", "now"] }); @@ -152,6 +190,44 @@ describe("genclass: the home (src/home.mjs)", () => { expect(after).toContain("That did not work: boom"); }); + it("y runs what a 'Did you mean' named; a number no command has says so; on Free the list is what works there (the 0.3.9 user stories)", async () => { + const ran: string[][] = []; + type("dashbord", "y", "99", "q"); + const said = await at58(async () => expect(await home({}, { runCommand: async (a: string[]) => (ran.push(a), 0), nearest })).toBe(0)); + expect(ran).toEqual([["dashboard"]]); + const flat = plainLines(said).join(" ").replace(/\s+/g, " "); + expect(flat).toContain("Did you mean dashboard? (y runs it)"); + expect(flat).toContain("No command has the number 99."); + // y with nothing named before it is not a yes to anything + type("y", "q"); + const plain = await at58(async () => expect(await home({}, { runCommand: async (a: string[]) => (ran.push(a), 0), nearest })).toBe(0)); + expect(ran).toHaveLength(1); + expect(plainLines(plain).join(" ")).toContain('There is no command "y"'); + // Free: the short list is the commands that work without Pro, and 1 is upgrade + type("1", "q"); + const free = await at58(async () => expect(await home({ free: true }, { runCommand: async (a: string[]) => (ran.push(a), 0), nearest })).toBe(0)); + expect(ran[1]).toEqual(["upgrade"]); + const freeFlat = plainLines(free).join(" ").replace(/\s+/g, " "); + expect(freeFlat).toContain("You are on Free: the other commands come with Pro."); + expect(freeFlat).not.toContain("what GenClass found on your live site"); + expect(tooWide(free)).toEqual([]); + }); + + it("opening: signed out in a folder that is set up says to sign in, not that it is not set up (the 0.3.9 user stories)", async () => { + const { opening } = await import("../src/home.mjs"); + const base = { app: { name: "shop", framework: "Vite", token: "gc_x" }, steps: [], projects: [], account: null, host: null }; + expect(plainLines(await at58(() => opening({ ...base, signedIn: false, sessionProblem: null } as never))).join(" ")).toContain("Not signed in. Type login to sign in again."); + expect(plainLines(await at58(() => opening({ ...base, signedIn: false, sessionProblem: "expired" } as never))).join(" ")).toContain("Your sign-in expired. Type login to sign in again."); + const fresh = plainLines(await at58(() => opening({ ...base, app: { name: "shop", token: null }, signedIn: false, sessionProblem: null } as never))).join(" ").replace(/\s+/g, " "); + expect(fresh).toContain("Not signed in, and GenClass is not in this app yet. Type setup"); + const unset = plainLines(await at58(() => opening({ ...base, app: { name: "shop", token: null }, signedIn: true, sessionProblem: null } as never))).join(" "); + expect(unset).toContain("GenClass is not set up in this folder yet."); + // on Free the next thing is Pro, not setup + const free = plainLines(await at58(() => opening({ ...base, app: { name: "shop", token: null }, signedIn: true, sessionProblem: null, account: { cli: { paid: false, trialDays: 3 } } } as never))).join(" ").replace(/\s+/g, " "); + expect(free).toContain("You are on Free. Type upgrade to start your 3-day free trial of Pro"); + expect(free).not.toContain("Type setup"); + }); + it("a command typed with a word it does not take is told so, not run as something else", async () => { const errs: string[] = []; const spy = (await import("vitest")).vi.spyOn(process.stderr, "write").mockImplementation(((s: unknown) => (errs.push(String(s)), true)) as never); @@ -186,7 +262,8 @@ describe("genclass: the walk leads into the home (src/setup.mjs)", () => { const nested = await at58(async () => expect(await setup({ cwd: dir, home: false }, { home: async () => (opened.push({}), 0) })).toBe(0)); expect(opened).toHaveLength(1); expect(nested).toContain("q stop"); - expect(plainLines(nested).join(" ")).toContain("Stopped. Run npx genclass again any time"); + // inside the prompt, setup is typed again + expect(plainLines(nested).join(" ")).toContain("Setup stopped here. Type setup to go on with it."); }); it("in the home a view is not headed again, and names its app only when it is another one", async () => { diff --git a/packages/cli/test/server-account.test.ts b/packages/cli/test/server-account.test.ts index 8f0b2465..57ffca98 100644 --- a/packages/cli/test/server-account.test.ts +++ b/packages/cli/test/server-account.test.ts @@ -117,7 +117,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 (1 app, 100 live fixes a month, 7 days of history), on the website. Every genclass command comes with Pro, 3 days free, then $15 / developer / month. Start it: npx genclass upgrade"); + expect(flat(line.text)).toContain("You are on Free (1 app, 100 live fixes a month, 7 days of history), on the website. Every genclass command comes with Pro, 3 days free: 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); diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index a6516e3a..d11d56de 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -226,7 +226,12 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { const pro = await readState({ cwd: dir }, { runGit: noGit, fetchFn }); expect(pro.next?.id).toBe("runtime"); const j = stateJson(pro); - expect(j.next).toMatchObject({ id: "runtime", title: "Add GenClass to this app", run: "npx genclass init", human: null }); + // an agent is told the command that does it without a terminal: init alone writes nothing there, so it would be + // told "npx genclass init" again and again + expect(j.next).toMatchObject({ id: "runtime", title: "Add GenClass to this app", run: "npx genclass setup --yes", human: null }); + expect(j.steps.find((x) => x.id === "runtime")?.run).toBe("npx genclass setup --yes"); + // a step that does not apply here (no git repository) has nothing to run + expect(j.steps.find((x) => x.id === "workflow")).toMatchObject({ state: "optional", run: null, human: null }); // every step has a short name, what doing it is called, one sentence and a fuller one for (const x of [...s.steps, ...pro.steps]) expect([x.title.length > 0, x.doing.length > 0, x.why.length > 0 && x.why.length < 100, x.more.length > 0], x.id).toEqual([true, true, true, true]); expect(j.steps.map((x) => x.id)).toEqual(["account", "plan", "runtime", "app-plan", "dashboard", "workflow", "github-app", "agent", "suggestions"]); @@ -375,7 +380,8 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s expect(s.account?.cli).toEqual({ paid: false, plan: "Pro", price: "$15 / developer / month", trialDays: 3, checkoutUrl: CHECKOUT }); // Pro (paid, in its trial, a grant): done, and the runtime is next const paid = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview(PRO) } }).fetchFn }); - expect(paid.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "Pro" }); + // done, with no "Pro" after the step's own name Pro + expect(paid.steps.find((x) => x.id === "plan")).toMatchObject({ state: "done", detail: "" }); expect(paid.next?.id).toBe("runtime"); // a member of a paying team (the server says why: via "team") const member = await readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ cli: { paid: true, via: "team" } }) } }).fetchFn }); @@ -465,23 +471,23 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s const url = `${HOST}/account/billing?project=p1`; const app = (extra: object) => project({ id: "p1", plan: { id: "team", name: "Pro", source: "paid" }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); const read = async (p: object, over: object = PRO) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ ...over, projects: [p] }) } }).fetchFn }); - // Pro covers it, no choice yet: Continue with Pro, on its page + // Pro covers it, no choice yet: it has its plan, so the step is done and never next (no page opens, nothing waits) const s = await read(app({})); - expect(s.next?.id).toBe("app-plan"); + expect(s.next?.id).not.toBe("app-plan"); const step = s.steps.find((x) => x.id === "app-plan")!; - expect(step).toMatchObject({ doing: "Continue with Pro for this app", detail: "covered by Pro: continue on its plan page", human: `open this app's plan page: ${url}&from=cli`, after: "runtime" }); + expect(step).toMatchObject({ state: "done", detail: "covered by Pro", after: "runtime" }); expect(step.offer).toMatchObject({ checkoutUrl: `${url}&from=cli`, project: { id: "p1" }, needs: false, current: "Pro", paying: true }); - // continued: nothing to choose - expect((await read(app({ plan_chosen: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ state: "optional", detail: "Pro" }); + // continued: done, the same + expect((await read(app({ plan_chosen: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ state: "done", detail: "Pro" }); expect((await read(app({ plan: { id: "team", name: "Pro", source: "comp" }, plan_chosen: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ detail: "Pro (set up by GenClass)" }); // a fourth app on Pro: locked, more apps are Enterprise's; a choice made earlier does not close it const fourth = (await read(app({ plan: { id: "free", name: "Free", source: null }, needs_plan: true, plan_chosen: true }))).steps.find((x) => x.id === "app-plan")!; expect(fourth).toMatchObject({ state: "next", detail: "Your plan covers no more apps", why: "Your plan covers no more apps. More apps come with Enterprise, by email." }); expect(fourth.offer).toMatchObject({ needs: true, paying: true, current: null }); - // with nobody at a terminal the page is opened (here: it cannot be) and the step stays open - const said = await printed(async () => expect(await chooseAppPlan(await read(app({})), { yes: true, noBrowser: true })).toBe("skipped")); - expect(flat(said)).toContain(`Open ${url}&from=cli`); - expect(flat(said)).toContain("Your Pro plan covers shop: continue with it on its plan page."); + // the fourth app with nobody at a terminal: the line and the address, and the walk goes on + const said = await printed(async () => expect(await chooseAppPlan(await read(app({ plan: { id: "free", name: "Free", source: null }, needs_plan: true })), { yes: true, noBrowser: true })).toBe("skipped")); + expect(flat(said)).toContain(`${url}&from=cli`); + expect(flat(said)).toContain("Your plan covers no more apps, and shop is one more."); // an app of a team the account is in: its owner chooses expect((await read(project({ shared: true }))).steps.find((x) => x.id === "app-plan")).toMatchObject({ state: "optional", detail: "the team's owner chooses it" }); // a server that sells nothing and an app on Free: nothing to choose @@ -496,15 +502,16 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s const url = `${HOST}/account/billing?project=p1`; const app = (extra: object) => project({ id: "p1", plan: { id: "team", name: "Pro", source: "paid" }, needs_plan: false, plan_chosen: false, plan_url: url, ...extra }); const read = (p: object) => readState({ cwd: dir }, { runGit: noGit, fetchFn: fakeServer({ "GET /api/cli/overview": { json: overview({ ...PRO, projects: [p] }) } }).fetchFn }); - // nobody at a terminal here: it says where the page is and goes on - // an app Pro covers has a plan: the line says so (it continues once on its page), and nothing waits on it + // an app Pro covers has a plan: one line says so, and nothing opens or waits (on a terminal either) let r = ""; - const said = await printed(async () => (r = await planNow({ cwd: dir }, { readState: async () => read(app({})) }))); + const said = await printed(async () => (r = await planNow({ cwd: dir, announce: true }, { readState: async () => read(app({})) }))); expect(r).toBe("covered"); - expect(flat(said)).toContain(`shop is covered by your Pro plan. Continue with it once on its plan page: ${url}&from=cli`); - expect(flat(said)).not.toContain("has no plan yet"); + expect(flat(said)).toContain("shop is on your Pro plan."); + // before a view it is said nothing: the app has its plan + expect(await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(app({})) })).toBe("covered"))).toBe(""); + expect(flat(said)).not.toMatch(/has no plan yet|Open |Waiting/); // the app named by its id, the same - expect(await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(app({})) }, "p1")).toBe("covered"))).toContain("covered by your Pro plan"); + expect(await printed(async () => expect(await planNow({ cwd: dir, announce: true }, { readState: async () => read(app({})) }, "p1")).toBe("covered"))).toContain("on your Pro plan"); // a fourth app on Pro: nothing to pay for (more apps are Enterprise's), so nothing is opened or waited for const fourth = app({ plan: { id: "free", name: "Free", source: null }, needs_plan: true }); const locked = await printed(async () => expect(await planNow({ cwd: dir }, { readState: async () => read(fourth) })).toBe("locked")); @@ -537,7 +544,7 @@ describe("genclass: Pro right after the sign-in, and each app's plan page (src/s expect(card).toContain("P R O"); expect(card).toContain("$15 / developer / month"); expect(card).toContain("Everything for up to 3 apps, per developer."); - expect(card.replace(/[│\s]+/g, " ")).toContain("3 days free, then $15 / developer / month. Cancel any time."); + expect(card.replace(/[│\s]+/g, " ")).toContain("The first 3 days are free. Cancel any time."); expect(card).toContain("✔ Visitor analytics"); expect(card.replace(/[│\s]+/g, " ")).toContain("✔ Alerts to Sentry, PostHog, Datadog, Slack or a webhook"); } diff --git a/packages/cli/test/views.test.ts b/packages/cli/test/views.test.ts index 6363ab80..a34e6b03 100644 --- a/packages/cli/test/views.test.ts +++ b/packages/cli/test/views.test.ts @@ -125,13 +125,14 @@ describe("genclass problems, events and health (src/views.mjs)", () => { const said = await at(58, () => renderEvents(app, { now: NOW })); expect(tooWide(said)).toEqual([]); const lines = plainLines(said); - const first = lines.findIndex((l) => /3 min ago\s+stale\s+94%\s+\/search/.test(l)); + // each kind by the name problems and fix give it + const first = lines.findIndex((l) => /3 min ago\s+Stale responses\s+94%\s+\/search/.test(l)); expect(first).toBeGreaterThan(-1); expect(lines[first + 1]).toMatch(/→ Dropped a stale write/); - expect(said).toMatch(/40 min ago\s+duplicate\s+88%\s+\/checkout/); + expect(said).toMatch(/40 min ago\s+Double submits\s+88%\s+\/checkout/); // reported only: no line under it; no confidence: none shown - expect(lines[lines.findIndex((l) => l.includes("duplicate")) + 1]).not.toContain("→"); - expect(said).toMatch(/9 h ago\s+odd plugin ki…\s+\/a\/very/); + expect(lines[lines.findIndex((l) => l.includes("Double submits")) + 1]).not.toContain("→"); + expect(said).toMatch(/9 h ago\s+odd plugin kind\s+\/a\/very/); expect(said).toContain("npx genclass export"); const all = await at(58, () => renderEvents(app, { now: NOW, all: true })); const flatAll = plainLines(all).join(" ").replace(/\s+/g, " "); diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index abb8ba48..3077e9e3 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -6,7 +6,8 @@ command was busy (a package install) is sent once more on a new one; a token that could not be created for a network error says to run the command again, not to sign in again; `connect` names the host it signs in at. Under the `genclass` command, rows and notes wrap to the terminal (58 columns and up) and `whoami` stacks a long - app row; `genclass-runtime`'s own output is unchanged. + app row; `genclass-runtime`'s own output is unchanged. `connect`'s question names the folder only when it is + not the current one, and `logout`'s last line wraps under the `genclass` command. - The CLI's note under a dashboard link says what each plan shows since genclass.dev's plans changed (2026-11-12): Free the totals for the last 7 days; every problem, its pages, fix prompts and visitor analytics with Pro (3 days diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs index 7715daa6..7003cd07 100644 --- a/packages/runtime/bin/lib/init.mjs +++ b/packages/runtime/bin/lib/init.mjs @@ -433,7 +433,7 @@ function briefTelemetry(cfg) { * words (a narrow terminal would otherwise break a word of it in two, "--n" and "o-audit"). */ function commandLine(label, cmd) { - const cols = Math.max(40, Math.min(100, Number(process.stdout.columns) || 100)); + const cols = Math.max(40, Math.min(100, Number(process.stdout.columns) || Number(process.env.COLUMNS) || 100)); if (2 + label.length + 2 + cmd.length <= cols) { out(` ${c.bold(label)} ${c.cyan(cmd)}`); return; @@ -451,7 +451,7 @@ function commandLine(label, cmd) { /** A grey sentence of the compact (--brief) output, folded to the window (40 to 100 columns), every line indented. */ function briefLines(text, paint = c.gray) { - const cols = Math.max(40, Math.min(100, Number(process.stdout.columns) || 100)) - 4; + const cols = Math.max(40, Math.min(100, Number(process.stdout.columns) || Number(process.env.COLUMNS) || 100)) - 4; let line = ""; for (const word of String(text).split(/\s+/).filter(Boolean)) { if (line && line.length + 1 + word.length > cols) { diff --git a/packages/runtime/bin/lib/login.mjs b/packages/runtime/bin/lib/login.mjs index e50bc07c..c98ff9fd 100644 --- a/packages/runtime/bin/lib/login.mjs +++ b/packages/runtime/bin/lib/login.mjs @@ -32,7 +32,7 @@ import { apiHost, hostConfig, hostUrl } from "./host.mjs"; import { FREE_LINES, UsageError, accountLabel, hostNotice, init, inspectProject, parse, versionNotice } from "./init.mjs"; import { AUTO } from "./plan.mjs"; import { LOCAL_FILE, tokenInFiles } from "./token.mjs"; -import { banner, c, choose, columns, confirm, err, interactive, more, out, row, sym } from "./ui.mjs"; +import { banner, c, choose, columns, confirm, err, interactive, more, out, row, sym, wrapTo } from "./ui.mjs"; import { CMD, HOST_CLI } from "./version.mjs"; export const USAGE = `Usage: @@ -386,7 +386,8 @@ async function afterLogin(o, creds, me) { } const where = relative(process.cwd(), st.cwd) || "."; const name = st.project.pkg?.name || st.project.name; - const ok = o.yes ? true : await confirm(`Connect ${name} (${where}, ${st.project.label}) to ${HOST_CLI?.into ?? "your account"}?`); + // the folder is named only when it is not this one ("." says nothing) + const ok = o.yes ? true : await confirm(`Connect ${name} (${where === "." ? "" : `${where}, `}${st.project.label}) to ${HOST_CLI?.into ?? "your account"}?`); if (ok === null) { out(` Not a terminal: run ${c.cyan(`${CMD} connect --yes`)} here to connect this app.`); out(); @@ -458,7 +459,7 @@ async function login(o) { out(); return 1; } - out(` Open this link, sign in (Google, email and password, GitHub or a passkey) and approve the code:`); + more("Open this link, sign in (Google, email and password, GitHub or a passkey) and approve the code:", 2); out(` ${c.bold(c.cyan(dev.verification_uri_complete))}`); out(); row("Code", `${c.bold(dev.user_code)} ${c.gray("(check that the page shows the same code)")}`); @@ -466,7 +467,7 @@ async function login(o) { if (openBrowser(dev.verification_uri_complete, o)) out(` ${c.gray("Opening it in your browser.")}`); else out(` ${c.gray("Open the link in a browser on any device.")}`); const minutes = Math.max(1, Math.round(dev.expires_in / 60)); - out(` Waiting for approval ${c.gray(`(the code expires in ${minutes} minute${minutes === 1 ? "" : "s"}; Ctrl-C to cancel)`)}`); + more(`Waiting for approval ${c.gray(`(the code expires in ${minutes} minute${minutes === 1 ? "" : "s"}; Ctrl-C to cancel)`)}`, 2); const ctl = new AbortController(); const onSigint = () => ctl.abort(); @@ -502,7 +503,7 @@ async function login(o) { } out(); row("Signed in", `${c.bold(accountLabel(creds.account))}${creds.account.github_login && creds.account.email ? c.gray(` (GitHub ${creds.account.github_login})`) : ""}`); - out(` ${c.gray(`Session saved in ${tilde(path)} (only you can read it).`)}`); + more(c.gray(`Session saved in ${tilde(path)} (only you can read it).`), 2); out(` ${c.gray(`Sign out: ${CMD} logout`)}`); out(); return afterLogin(o, creds, null); @@ -635,7 +636,7 @@ async function logout() { return 1; } row("Signed out", accountLabel(creds.account)); - out(` ${c.gray(`The CLI session is ended at genclass.dev, and ${tilde(path)} is deleted.`)}`); + more(c.gray(`The CLI session is ended at genclass.dev, and ${tilde(path)} is deleted.`), 2); out(); return 0; } @@ -668,13 +669,14 @@ export async function run(cmd, argv) { } catch (e) { if (e instanceof UsageError) { // under the genclass command, one short line: its own help names every command, within 58 columns - err(HOST_CLI ? `${SELF} ${cmd}: ${e.message}. Every command and option: ${CMD} help` : `${SELF} ${cmd}: ${e.message}\n\n${usage()}`); + if (HOST_CLI) for (const l of wrapTo(`${SELF} ${cmd}: ${e.message}. Every command and option: ${CMD} help`, columns() - 2)) err(l); + else err(`${SELF} ${cmd}: ${e.message}\n\n${usage()}`); return 2; } throw e; } if (o.help) { - if (HOST_CLI) more(`${CMD} ${cmd}: ${HOST_HELP[cmd] ?? ""} Every command and option: ${CMD} help`, 2); + if (HOST_CLI) more(`${CMD} ${cmd}: ${HOST_HELP[cmd] ?? ""} All commands: ${CMD} help`, 2); else out(usage()); return 0; }