Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

## 0.3.4

From a review of every setup path after a teammate hit the 0.3.2 bug:

- **The GitHub App step is offered for every repository on GitHub.** It waited for Playwright tests, so a project
without them (most) never saw it. The app also comments production findings on pull requests, which need no tests.
- **A hand-written setup is no longer stuck at "Put this app in your account".** Its key (`GenClass.init({ token })`,
`GENCLASS_CONFIG`, a meta tag) was not read, so the step came back on every run even with the app in the account.
- **An app set up with `@genclass/nuxt` or `@genclass/astro` is not treated as new**, so the walk no longer adds a
second runtime import and a second app.
- **`--yes` no longer replaces a key that is not the account's** (a teammate's): `connect` says which options name
the app instead.
- `setup --yes --json` prints only JSON on stdout; `status --json` names `npx genclass setup --yes` where a step
needs it, so a coding agent no longer loops on `npx genclass setup`.
- Saying no to the runtime's change no longer leads to the Vite settings; signed out, the first-page and GitHub App
steps say why they do nothing.
- Commands now run as the screen prints them: the signed-out `dashboard` with `--all`, `--range` or `--app`;
`connect`, `init` and `audit` typed at the home prompt (they were sent to the model as questions); the CSP advice's
`fetch-model`; `scan` of a local address; `--app` with a long or quoted name; "which app" and "sign in again" hints
that dropped what was typed after the command.
- `remove` run from a folder inside the app finds GenClass's other files.
- `init` and `connect` run through `genclass` add no developer overlay, name themselves `npx genclass` in their help
and errors, and point a newer runtime at `npx genclass@latest`.

## 0.3.3

- **"Put this app in your account" works.** For an app that already has GenClass in it but is not in the signed-in
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "genclass",
"version": "0.3.3",
"version": "0.3.4",
"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",
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/src/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { dirname, join, relative, sep } from "node:path";
import { pollDeviceLogin, readCredentials, saveCredentials, startDeviceLogin } from "../../runtime/bin/lib/account.mjs";
import { detectProject } from "../../runtime/bin/lib/detect.mjs";
import { apiHost, hostConfig, hostUrl } from "../../runtime/bin/lib/host.mjs";
import { accountLabel, run as runInit } from "../../runtime/bin/lib/init.mjs";
import { accountLabel, inspectProject, run as runInit } from "../../runtime/bin/lib/init.mjs";
import { run as runLogin } from "../../runtime/bin/lib/login.mjs";
import { c, interactive, out, printChange, stopOnEnter, sym } from "../../runtime/bin/lib/ui.mjs";
import { fetchOverview, fetchRepoStatus } from "./api.mjs";
Expand Down Expand Up @@ -191,6 +191,8 @@ export async function addRuntime(state, o = {}) {
const { cmd, args } = runtimeArgs(state, o);
const code = cmd === "connect" ? await runLogin("connect", args) : await runInit("init", args);
if (code !== 0) return "failed";
// init returns 0 when the person says no to its change: then there is no GenClass to add Vite settings for
if (inspectProject({ cwd: state.cwd }).fresh) return "skipped";
}
return (await addViteSettings(state, o)) === "failed" ? "failed" : "done";
}
Expand Down Expand Up @@ -239,7 +241,10 @@ export async function addViteSettings(state, o = {}) {
/** Waits (up to `seconds`) for genclass.dev to have received a first event from this app. */
export async function watchFirstPage(state, o = {}) {
const creds = readCredentials();
if (!creds || !state.app.token) return "skipped";
if (!creds || !state.app.token) {
note(!creds ? "Sign in first: npx genclass login. The first page is looked for in your account." : "Put this app in your account first: npx genclass connect");
return "skipped";
}
if (!o.yes && interactive()) {
const ok = await agree("Is your site deployed with GenClass in it? I will watch for its first page for two minutes", false);
if (!ok) {
Expand Down Expand Up @@ -340,7 +345,10 @@ async function waitForGithub(creds, repo, text, until, seconds) {
export async function connectGithubApp(state, o = {}) {
const creds = readCredentials();
const repo = state.git.github;
if (!creds || !repo) return "skipped";
if (!creds || !repo) {
note(!creds ? "Sign in first: npx genclass login. The GitHub App is connected to your account." : "This folder is not a repository on GitHub, so there is nothing to connect.");
return "skipped";
}
// a genclass.dev with no GitHub App (a collector someone hosts themselves): nothing to connect, and no waiting
if (state.account?.github && state.account.github.app === false) {
note("This genclass.dev has no GitHub App, so there is nothing to connect here.");
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,11 @@ export async function dashboard(o = {}, deps = {}) {
}

/** How an app is named after --app: its name in quotes, or its id when another app has the same name. */
export const appArg = (p, projects) => (projects.filter((x) => x.name.toLowerCase() === p.name.toLowerCase()).length > 1 ? p.id : `"${clean(p.name, 40)}"`);
export const appArg = (p, projects) => {
const name = clean(p.name, 200);
// a name that is shared, cut, or holds a quote is not typed: the id always works
return projects.filter((x) => x.name.toLowerCase() === p.name.toLowerCase()).length > 1 || name !== p.name || /["\\$`]/.test(name) ? p.id : `"${name}"`;
};

/**
* Which app a command is about, for a signed-in account: --app by name or id, else this folder's app, else the
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ask.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function ask(o = {}, deps = {}) {
}
return 2;
}
const picked = await pickApp(o, deps, "ask");
const picked = await pickApp(o, deps, `ask "${question.replace(/["\\$`]/g, "")}"`);
if ("code" in picked) return picked.code;
const { state, creds, target } = picked;
const range = ["24h", "7d", "30d", "90d", "1y"].includes(o.range) ? o.range : "7d";
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export async function run(argv, ctx = {}) {
}
if (cmd === "dashboard") {
// signed out, the runtime CLI's own command still knows an app's private dashboard link
const fallback = async () => (await import("../../runtime/bin/lib/login.mjs")).run("dashboard", args);
const fallback = async () => (await import("../../runtime/bin/lib/login.mjs")).run("dashboard", [...(o.cwd ? ["--cwd", o.cwd] : []), ...(o.open ? ["--open"] : [])]);
return (await import("./app.mjs")).dashboard(o, { fallback });
}
if (["problems", "events", "health", "report"].includes(cmd)) return (await import("./views.mjs"))[cmd](o);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/home.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const COMMANDS = [
["remove", "take GenClass out of this app", false],
];

const NAMES = [...COMMANDS.map(([n]) => n.split(" ")[0]), "logout", "whoami", "version"];
const NAMES = [...COMMANDS.map(([n]) => n.split(" ")[0]), "logout", "whoami", "version", "connect", "init", "audit"];

/** A typed line as arguments: spaces separate, single or double quotes keep words together. */
export function words(line) {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/manage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ const POSTHOG = { us: "https://us.i.posthog.com", eu: "https://eu.i.posthog.com"
* `genclass connections add|test|on|off|remove <slack|sentry|posthog|datadog|webhook>` changes one destination.
*/
export async function connections(o = {}, deps = {}) {
const picked = await pickApp(o, deps, "connections");
const picked = await pickApp(o, deps, ["connections", ...(o.args ?? [])].join(" "));
if ("code" in picked) return picked.code;
const { state, creds, target } = picked;
const dash = target.dashboard_url ?? hostUrl("/account");
Expand Down Expand Up @@ -422,7 +422,7 @@ export async function exportEvents(o = {}, deps = {}) {
process.stderr.write(`genclass export: ${r.reauth ? "sign in again: npx genclass login" : r.upgradeUrl ? "the stored events as a file come with the Team plan: npx genclass plan" : (r.reason ?? "it did not work")}\n`);
return 1;
}
return refused(r, { what: "The stored events as a file", command: "export", browserUrl: target.dashboard_url, json: o.json });
return refused(r, { what: "The stored events as a file", command: ["export", o.from ? `--from ${o.from}` : "", o.to ? `--to ${o.to}` : "", o.format ? `--format ${o.format}` : "", o.out ? `--out ${o.out}` : ""].filter(Boolean).join(" "), browserUrl: target.dashboard_url, json: o.json });
}
parts.push(r.text);
pages++;
Expand Down Expand Up @@ -476,7 +476,7 @@ export async function exportEvents(o = {}, deps = {}) {
* a page ending in * matches everything that starts with it); `genclass funnels remove "Name"` removes one.
*/
export async function funnels(o = {}, deps = {}) {
const picked = await pickApp(o, deps, "funnels");
const picked = await pickApp(o, deps, ["funnels", ...(o.args ?? []).map((w) => (/\s/.test(w) ? `"${w}"` : w))].join(" "));
if ("code" in picked) return picked.code;
const { state, creds, target } = picked;
const [sub, name, ...steps] = o.args ?? [];
Expand Down Expand Up @@ -514,7 +514,7 @@ export async function funnels(o = {}, deps = {}) {
return 2;
}
const r = await asking(o, creds, (cr) => saveFunnels(cr, target.id, next, deps), deps);
if (!r.ok) return refused(r, { what: "Funnels (part of visitor analytics)", command: `funnels ${sub}`, browserUrl: target.dashboard_url, json: o.json });
if (!r.ok) return refused(r, { what: "Funnels (part of visitor analytics)", command: ["funnels", sub, name ? `"${name}"` : "", ...steps].filter(Boolean).join(" "), browserUrl: target.dashboard_url, json: o.json });
if (o.json) out(JSON.stringify({ ok: true, funnels: r.funnels }));
else {
good(sub === "add" ? `Saved the funnel ${c.bold(clean(name, 60))}: ${steps.map((x) => clean(x, 30)).join(` ${sym.arrow} `)}` : `Removed the funnel ${c.bold(clean(name, 60))}.`);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/remove.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ export async function remove(args, deps = {}) {
const code = await runtime();
if (code !== 0) return code;
const o = flags(args);
const plan = planLeftovers(o.cwd, deps);
// from a folder inside the app, the app's own folder (the runtime's remove walks up the same way)
const { projectDir } = await import("../../runtime/bin/lib/init.mjs");
const plan = planLeftovers(projectDir({ cwd: args.some((a) => a === "--cwd" || a.startsWith("--cwd=")) ? o.cwd : undefined }), deps);
if (!plan.changes.length && !plan.kept.length) return 0;
out(` ${c.bold("Also set up by this command")}`);
for (const ch of plan.changes) out(` ${ch.kind === "delete" ? c.red("delete") : c.yellow("edit ")} ${c.bold(ch.file)} ${c.gray(ch.what)}`);
Expand Down
20 changes: 18 additions & 2 deletions packages/cli/src/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// by another tool counts.
// - On a terminal a step starts on Enter, and Enter is the yes for what the step's sentence says it will write (s
// skips it for this run, q stops). With --yes nothing is asked: every step that needs no person is done, and what
// only a person can do (approve a sign-in, install the GitHub App, deploy) is printed with its address. Without a
// only a person can do (install the GitHub App, deploy) is printed with its address; the sign-in prints its link
// and code and waits for the approval (up to ten minutes), so a coding agent can hand them to the person. Without a
// terminal and without --yes nothing is done at all: the view and the next command are printed, which is what a
// coding agent reads. Input that ends at a question (end of file) stops the walk: an empty answer is an Enter only
// while someone can still type.
Expand Down Expand Up @@ -136,6 +137,22 @@ export async function status(o = {}) {
export async function setup(o = {}, deps = {}) {
const auto = !!o.yes;
if (!auto && !interactive()) return status(o);
// --json: the walk's view goes to stderr, so stdout is the JSON alone and parses
if (o.json) {
const write = process.stdout.write;
process.stdout.write = process.stderr.write.bind(process.stderr);
try {
return await walk({ ...o, json: false }, deps, auto);
} finally {
process.stdout.write = write;
out(JSON.stringify(stateJson(await readState({ cwd: o.cwd })), null, 2));
}
}
return walk(o, deps, auto);
}

/** The walk itself (setup): every step in order until nothing is left that this machine can do. */
async function walk(o, deps, auto) {
const skip = [];
let failed = false;
let details = !!o.all;
Expand Down Expand Up @@ -240,6 +257,5 @@ export async function setup(o = {}, deps = {}) {
out();
rule();
}
if (o.json) out(JSON.stringify(stateJson(await readState({ cwd: o.cwd })), null, 2));
return failed ? 1 : 0;
}
11 changes: 6 additions & 5 deletions packages/cli/src/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -290,16 +290,14 @@ export async function readState(o = {}, deps = {}) {
if (!isApp || !git.github) Object.assign(gh, { state: "optional", detail: !isApp ? "" : git.repo ? "the repository is not on GitHub" : "not a git repository yet" });
// a genclass.dev with no GitHub App (a collector someone hosts themselves): nothing to connect
else if (me?.github && me.github.app === false) Object.assign(gh, { state: "optional", detail: "this genclass.dev has no GitHub App" });
else if (!workflows.length && !playwright) Object.assign(gh, { state: "optional", detail: "needs Playwright tests first" });
else if (!workflows.length) Object.assign(gh, { detail: "after the test workflow" });
else if (!me) Object.assign(gh, { detail: "sign in to check" });
else if (!repo || !repo.ok) Object.assign(gh, repo?.missing ? { state: "waiting", detail: "check it at genclass.dev/account/github", run: null } : { detail: `could not check${repo ? ` (${repo.reason})` : ""}` });
else if (!repo.known) Object.assign(gh, { detail: "continue with GitHub to check", human: "install the GitHub App and continue with GitHub in your browser" });
else if (!repo.installed) Object.assign(gh, { detail: `not on ${git.github} yet`, human: "install the GitHub App on this repository" });
else if (repo.needsPlan) Object.assign(gh, { state: "waiting", detail: "private repository: reviews come with the Team plan", run: null });
else if (repo.paused) Object.assign(gh, { state: "waiting", detail: "paused on your GitHub page at genclass.dev", run: null });
else if (repo.evidence) Object.assign(gh, { state: "done", detail: repo.prs ? `reviewing · ${repo.prs} pull request${repo.prs === 1 ? "" : "s"} so far` : "reviewing" });
else Object.assign(gh, { state: "waiting", detail: "installed · open a pull request and the first review appears", run: null, human: "commit the workflow file and open a pull request" });
else Object.assign(gh, { state: "waiting", detail: "installed · open a pull request and the first review appears", run: null, human: workflows.length ? "commit the workflow file and open a pull request" : "open a pull request" });
steps.push({
id: "github-app",
title: "GitHub App",
Expand Down Expand Up @@ -362,14 +360,17 @@ export async function readState(o = {}, deps = {}) {
}

/** 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);

export function stateJson(s) {
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 : x.run, human: x.state === "done" ? null : x.human })),
next: s.next ? { id: s.next.id, title: s.next.doing, run: s.next.run, human: s.next.human, why: s.next.why } : null,
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,
complete: s.complete,
};
}
2 changes: 2 additions & 0 deletions packages/cli/test/home.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ describe("genclass: the home (src/home.mjs)", () => {
expect(read("genclass problems")).toEqual({ command: ["problems"] });
expect(read("npx genclass dashbord")).toEqual({ unknown: "dashbord" });
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"] });
});

it("every command the home lists is one the command line runs, and the list fits 58 columns", async () => {
Expand Down
Loading
Loading