diff --git a/package-lock.json b/package-lock.json index 939aeb69..6f7bf614 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16819,7 +16819,7 @@ }, "packages/cli": { "name": "genclass", - "version": "0.3.3", + "version": "0.3.4", "license": "Apache-2.0", "bin": { "genclass": "bin/genclass.mjs" diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index fa25f694..f90415fa 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -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 diff --git a/packages/cli/package.json b/packages/cli/package.json index b46171dc..bb2c3d50 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/actions.mjs b/packages/cli/src/actions.mjs index 92aeb36d..6ea90ea8 100644 --- a/packages/cli/src/actions.mjs +++ b/packages/cli/src/actions.mjs @@ -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"; @@ -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"; } @@ -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) { @@ -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."); diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs index 5a0857d2..56fc127b 100644 --- a/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs @@ -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 diff --git a/packages/cli/src/ask.mjs b/packages/cli/src/ask.mjs index c82a07e4..ed78f863 100644 --- a/packages/cli/src/ask.mjs +++ b/packages/cli/src/ask.mjs @@ -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"; diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 3e75949e..f1fc0df0 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -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); diff --git a/packages/cli/src/home.mjs b/packages/cli/src/home.mjs index 5c9b5b32..623459f2 100644 --- a/packages/cli/src/home.mjs +++ b/packages/cli/src/home.mjs @@ -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) { diff --git a/packages/cli/src/manage.mjs b/packages/cli/src/manage.mjs index c2aabd7e..5d568c21 100644 --- a/packages/cli/src/manage.mjs +++ b/packages/cli/src/manage.mjs @@ -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 ` 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"); @@ -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++; @@ -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 ?? []; @@ -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))}.`); diff --git a/packages/cli/src/remove.mjs b/packages/cli/src/remove.mjs index bce835ab..e82f35b7 100644 --- a/packages/cli/src/remove.mjs +++ b/packages/cli/src/remove.mjs @@ -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)}`); diff --git a/packages/cli/src/setup.mjs b/packages/cli/src/setup.mjs index 11cacb20..a8a796c5 100644 --- a/packages/cli/src/setup.mjs +++ b/packages/cli/src/setup.mjs @@ -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. @@ -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; @@ -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; } diff --git a/packages/cli/src/state.mjs b/packages/cli/src/state.mjs index a9ab069f..5eceff50 100644 --- a/packages/cli/src/state.mjs +++ b/packages/cli/src/state.mjs @@ -290,8 +290,6 @@ 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" }); @@ -299,7 +297,7 @@ export async function readState(o = {}, deps = {}) { 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", @@ -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, }; } diff --git a/packages/cli/test/home.test.ts b/packages/cli/test/home.test.ts index 0ffcf8b1..6c9ba659 100644 --- a/packages/cli/test/home.test.ts +++ b/packages/cli/test/home.test.ts @@ -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 () => { diff --git a/packages/cli/test/state.test.ts b/packages/cli/test/state.test.ts index bb43497c..7b9d3acf 100644 --- a/packages/cli/test/state.test.ts +++ b/packages/cli/test/state.test.ts @@ -99,7 +99,9 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { // no workflow file yet: it is the next step, and the app comes after it const none = await read(); expect(wf(none)).toMatchObject({ state: "next", title: "Test workflow", doing: "Add the test workflow", detail: "Playwright found · no workflow file yet", run: "npx genclass setup" }); - expect(pr(none)).toMatchObject({ state: "todo", title: "GitHub App", detail: "after the test workflow" }); + // the GitHub App does not wait for the workflow: it also comments production findings, which need no tests + expect(pr(none).state).not.toBe("optional"); + expect(pr(none).detail).not.toBe("after the test workflow"); write(dir, { [WORKFLOW_PATH]: "name: genclass\njobs:\n genclass:\n steps:\n - run: node genclass-playwright-run.mjs\n", ".github/workflows/ci.yml": "name: ci\n" }); expect(genclassWorkflows(dir)).toEqual([WORKFLOW_PATH]); @@ -142,7 +144,8 @@ describe("genclass: the state of a project (src/state.mjs header)", () => { expect(await pr((a) => (a[1] === "--show-toplevel" ? dir : a[0] === "remote" ? "https://gitlab.com/acme/shop.git" : "main"))).toMatchObject({ state: "optional", detail: "the repository is not on GitHub" }); expect(await pr(onGithub(dir))).toMatchObject({ state: "optional", detail: "no Playwright tests yet" }); expect(await app(noGit)).toMatchObject({ state: "optional", detail: "not a git repository yet" }); - expect(await app(onGithub(dir))).toMatchObject({ state: "optional", detail: "needs Playwright tests first" }); + // without Playwright the test workflow does not apply, the GitHub App still does (production findings on pull requests) + expect((await app(onGithub(dir))).state).not.toBe("optional"); write(dir, { "playwright.config.ts": "export default {};\n" }); expect(await pr(onGithub(dir))).toMatchObject({ state: "todo" }); }); @@ -400,3 +403,43 @@ describe("genclass: the plan step (src/state.mjs, src/actions.mjs choosePlan, sr } }); }); + +describe("genclass: setups made without init's markers (review, 2026-11-12)", () => { + it("a hand-written setup whose key is in the account is done, not 'Put this app in your account' forever", async () => { + const dir = tempDir(); + viteApp(dir); + write(dir, { "src/main.jsx": `import "@genclass/runtime/auto";\nwindow.GENCLASS_CONFIG = { token: "${TOKEN}" };\n` }); + signIn(); + const server = fakeServer({ "GET /api/cli/overview": { json: overview({ projects: [project()] }) } }); + const s = await readState({ cwd: dir }, { runGit: noGit, fetchFn: server.fetchFn }); + expect(s.steps.find((x) => x.id === "runtime")).toMatchObject({ state: "done" }); + expect(s.steps.find((x) => x.id === "runtime")!.detail).toContain("added by hand"); + expect(s.next?.id).not.toBe("runtime"); + }); + + it("an app set up with @genclass/nuxt or @genclass/astro is not fresh: GenClass is not added a second time", async () => { + for (const [dep, file, text] of [ + ["nuxt", "nuxt.config.ts", `export default defineNuxtConfig({ modules: [["@genclass/nuxt", { token: "${TOKEN}" }]] });\n`], + ["astro", "astro.config.mjs", `import genclass from "@genclass/astro";\nexport default { integrations: [genclass({ token: "${TOKEN}" })] };\n`], + ] as const) { + const dir = tempDir(); + write(dir, { "package.json": JSON.stringify({ name: "site", dependencies: { [dep]: "^5.0.0", [`@genclass/${dep}`]: "^0.1.0" } }), [file]: text }); + const s = await readState({ cwd: dir }, { runGit: noGit }); + const rt = s.steps.find((x) => x.id === "runtime")!; + expect([dep, rt.run]).not.toEqual([dep, "npx genclass init"]); + expect([dep, s.app.token]).toEqual([dep, TOKEN]); + } + }); + + it("for a coding agent the command of a step is the one that runs without a terminal", async () => { + const { stateJson } = await import("../src/state.mjs"); + const dir = tempDir(); + viteApp(dir); + await addRuntime(dir); + signIn(); + const server = fakeServer({ "GET /api/cli/overview": { json: overview({ projects: [project()] }) } }); + const j = stateJson(await readState({ cwd: dir }, { runGit: noGit, fetchFn: server.fetchFn })); + for (const x of j.steps) expect([x.id, x.run]).not.toEqual([x.id, "npx genclass setup"]); + expect(j.next?.run === null || !/^npx genclass setup$/.test(j.next!.run ?? "")).toBe(true); + }); +}); diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 9ec780b3..f5135e55 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **`init`/`connect` recognise more existing setups:** `@genclass/nuxt` and `@genclass/astro` in a config, and the + key of a hand-written setup (`bin/lib/init.mjs` `inspectProject`). `connect --yes` refuses to replace a key that is + none of the account's apps unless `--app` or `--name` says which (`bin/lib/login.mjs`). Run by a host CLI + (`genclass`) they leave the developer overlay out and use its name in help, errors and hints. + - **`init` and `connect`: an app the account's plan has no room for points at an app it has.** When creating the app's token fails because of the plan's app limit, the hint was "run login again, then connect", which hits the same limit. It now says to pick one of the account's apps with `connect`, or make room on the account page diff --git a/packages/runtime/bin/lib/init.mjs b/packages/runtime/bin/lib/init.mjs index ef6b3091..30060afd 100644 --- a/packages/runtime/bin/lib/init.mjs +++ b/packages/runtime/bin/lib/init.mjs @@ -217,7 +217,10 @@ export function projectDir(o) { } const posix = (p) => p.split(sep).join("/"); -const REF_RE = /(?:from\s+|import\s*\(?\s*|require\s*\(\s*)['"]@genclass\/runtime(?:\/[\w/-]+)?['"]|genclass\.global(?:\.min)?\.js/; +// GenClass in a file outside init's markers: the runtime imported or required, the script-tag build, or one of the +// framework integrations (@genclass/nuxt in nuxt.config, @genclass/astro in astro.config), which add the runtime +// themselves +const REF_RE = /(?:from\s+|import\s*\(?\s*|require\s*\(\s*)['"]@genclass\/(?:runtime(?:\/[\w/-]+)?|nuxt|astro)['"]|['"]@genclass\/nuxt['"]|genclass\.global(?:\.min)?\.js/; /** Files that use GenClass outside init's markers (a hand-written setup). */ function manualRefs(files) { @@ -450,7 +453,8 @@ function cspAdvice(project, files, cfg) { if (!csp.cdnMentioned) out(` says so once in the console). Self-host it instead:`); else out(` Or self-host it (no third-party origin at all):`); const pub = publicDir(project); - out(` 1. ${c.cyan(`${CMD} fetch-model ${pub}/genclass-model`)} ${c.gray("(model and ONNX Runtime files: about 64 MB; --variant q8 --ort wasm: about 24 MB)")}`); + // fetch-model is the runtime package's own command (bin/genclass-runtime.mjs), not one of a host CLI's + out(` 1. ${c.cyan(`${HOST_CLI ? `npx ${PKG}@beta` : CMD} fetch-model ${pub}/genclass-model`)} ${c.gray("(model and ONNX Runtime files: about 64 MB; --variant q8 --ort wasm: about 24 MB)")}`); out(` 2. ${c.cyan(`${CMD} init --model-url /genclass-model/`)} ${c.gray("(writes model.baseUrl and ortWasmPaths)")}`); out(` or add https://cdn.jsdelivr.net to connect-src.`); } else { @@ -777,8 +781,12 @@ export async function versionNotice(o) { const from = st.tag ? c.gray(` (npm dist-tag ${st.tag})`) : asked ? "" : c.gray(" (npm dist-tags not checked)"); row("Version", `${PKG} ${VERSION}${from}`); if (st.newer) { - const run = st.newer.tag === "latest" ? `npx ${PKG}` : `npx ${PKG}@${st.newer.tag}`; - row("Newer", `${PKG} ${st.newer.version} is on npm (dist-tag ${st.newer.tag}): run ${c.cyan(`${run} init`)} instead`, c.yellow(sym.warn)); + // inside a host CLI (genclass), that CLI pins the runtime it installs: the way to the newer one is a newer CLI + if (HOST_CLI) row("Newer", `${PKG} ${st.newer.version} is on npm (dist-tag ${st.newer.tag}): the newest ${HOST_CLI.name ?? "command"} installs it (${c.cyan(`npx ${HOST_CLI.name ?? "genclass"}@latest`)})`, c.yellow(sym.warn)); + else { + const run = st.newer.tag === "latest" ? `npx ${PKG}` : `npx ${PKG}@${st.newer.tag}`; + row("Newer", `${PKG} ${st.newer.version} is on npm (dist-tag ${st.newer.tag}): run ${c.cyan(`${run} init`)} instead`, c.yellow(sym.warn)); + } } } @@ -797,7 +805,9 @@ export function inspectProject(o) { const marked = files.filter((f) => hasMarker(readText(f) ?? "")); const manual = marked.length ? [] : manualRefs(files); const local = readLocal(cwd); - return { cwd, project, files, marked, handWritten: !marked.length && manual.length > 0, fresh: !marked.length && !manual.length, token: tokenInFiles(marked) ?? local?.token ?? null, local }; + // the key: in init's markers, else in a hand-written setup's files (GenClass.init({ token }), a meta tag, the + // integrations' options), else in .genclass.local + return { cwd, project, files, marked, handWritten: !marked.length && manual.length > 0, fresh: !marked.length && !manual.length, token: tokenInFiles(marked) ?? tokenInFiles(manual) ?? local?.token ?? null, local }; } /** @@ -935,7 +945,8 @@ export async function init(o, ctx = {}) { return spawn(...installArgs(project.pm, spec), cwd) ? 0 : 1; } - const devtools = !o.noDevtools; + // nothing GenClass sets up through a host CLI (genclass) is drawn on a page: the overlay is left out there + const devtools = !o.noDevtools && !HOST_CLI; const planFor = (config) => planInit(project, { mode: o.mode, devtools, cdn: o.cdn, sri: !o.noSri, sync: !!o.sync, strategy: o.strategy, config }, { pkgDir: PKG_DIR, version: VERSION }); const plan = planFor(cfg); if (plan.manual || !plan.changes.length) { @@ -1146,6 +1157,11 @@ async function remove(o) { return 0; } +/** The name this command goes by: the host CLI's (`npx genclass`) when one runs it, else the runtime package's own. */ +const SELF = HOST_CLI ? CMD : "genclass-runtime"; +/** The usage text, with the commands written the way they are typed here. */ +const usage = () => (HOST_CLI ? USAGE.replace(/^ {2}genclass-runtime /gm, ` ${CMD} `) : USAGE); + /** Entry from bin/genclass-runtime.mjs. Returns the exit code. */ export async function run(cmd, argv) { let o; @@ -1153,18 +1169,18 @@ export async function run(cmd, argv) { o = parse(argv); } catch (e) { if (e instanceof UsageError) { - err(`genclass-runtime ${cmd}: ${e.message}\n\n${USAGE}`); + err(`${SELF} ${cmd}: ${e.message}\n\n${usage()}`); return 2; } throw e; } if (o.help) { - out(USAGE); + out(usage()); return 0; } const h = hostConfig(); if (cmd === "init" && h.error) { - err(`genclass-runtime ${cmd}: ${h.error}`); + err(`${SELF} ${cmd}: ${h.error}`); return 2; } return cmd === "remove" ? remove(o) : init(o); diff --git a/packages/runtime/bin/lib/login.mjs b/packages/runtime/bin/lib/login.mjs index 992b285e..17fb905e 100644 --- a/packages/runtime/bin/lib/login.mjs +++ b/packages/runtime/bin/lib/login.mjs @@ -32,7 +32,7 @@ import { FREE_LINES, UsageError, accountLabel, hostNotice, init, inspectProject, import { AUTO } from "./plan.mjs"; import { LOCAL_FILE, tokenInFiles } from "./token.mjs"; import { banner, c, choose, confirm, err, interactive, out, row, sym } from "./ui.mjs"; -import { CMD } from "./version.mjs"; +import { CMD, HOST_CLI } from "./version.mjs"; export const USAGE = `Usage: genclass-runtime login [--yes] [--no-browser] [--no-connect] [--app ] [--name ] @@ -262,6 +262,17 @@ async function connectApp(o, creds, st, me) { out(); } + // a key that is none of this account's apps (a teammate's, or one from another account) is replaced only when the + // person says with what: on a terminal by picking, or with --app or --name. --yes alone does not choose for them. + // (a key from this machine's .genclass.local is the person's own app made without an account, not someone else's) + if (st.token && !current && st.local?.token !== st.token && o.yes && !o.app && !o.name && !o.dryRun) { + err(` ${c.red(sym.cross)} This app's project key ${st.token} is not one of your account's apps (it may be a teammate's).`); + out(` Say which app it is: ${c.cyan(`${CMD} connect`)} on a terminal and pick, or ${c.cyan(`${CMD} connect --app `)}`); + out(` for one of yours, or ${c.cyan(`${CMD} connect --name `)} for a new one. Nothing was changed.`); + out(); + return 1; + } + // pick one of the account's apps or a new one; a new app is created only once the changes are approved const createName = String(o.name || pkgName).slice(0, 80); let pick; @@ -575,11 +586,16 @@ async function logout() { return 0; } +/** The name this command goes by: the host CLI's (`npx genclass`) when one runs it, else the runtime package's own. */ +const SELF = HOST_CLI ? CMD : "genclass-runtime"; +/** The usage text, with the commands written the way they are typed here. */ +const usage = () => (HOST_CLI ? USAGE.replace(/^ {2}genclass-runtime /gm, ` ${CMD} `) : USAGE); + /** Entry from bin/genclass-runtime.mjs. Returns the exit code. */ export async function run(cmd, argv) { const h = hostConfig(); if (h.error) { - err(`genclass-runtime ${cmd}: ${h.error}`); + err(`${SELF} ${cmd}: ${h.error}`); return 2; } let o; @@ -588,13 +604,13 @@ export async function run(cmd, argv) { if (o.app !== undefined && o.name !== undefined) throw new UsageError("--app and --name contradict each other (use an app, or create one)"); } catch (e) { if (e instanceof UsageError) { - err(`genclass-runtime ${cmd}: ${e.message}\n\n${USAGE}`); + err(`${SELF} ${cmd}: ${e.message}\n\n${usage()}`); return 2; } throw e; } if (o.help) { - out(USAGE); + out(usage()); return 0; } if (cmd === "login") return login(o); diff --git a/packages/runtime/bin/lib/scan.mjs b/packages/runtime/bin/lib/scan.mjs index 184c7f81..f8a526c4 100644 --- a/packages/runtime/bin/lib/scan.mjs +++ b/packages/runtime/bin/lib/scan.mjs @@ -747,7 +747,8 @@ export async function run(cmd, argv) { } if (hosted) { if (privateHost(url)) { - err(`${NAME} ${cmd}: ${new URL(url).host} is a local address: the hosted page check cannot reach it and it is never sent there. Run without --hosted (needs Playwright's Chromium: ${INSTALL_LINE}).`); + // a host CLI (genclass) asks for the hosted check itself, so the way to a local run there is --local + err(`${NAME} ${cmd}: ${new URL(url).host} is a local address: the hosted page check cannot reach it and it is never sent there. ${HOST_CLI ? "Add --local" : "Run without --hosted"} to check it on this machine (needs Playwright's Chromium: ${INSTALL_LINE}).`); return 2; } const tty = process.stderr.isTTY;