import { resolve } from "node:path"; import { defineCommand } from "citty"; import * as clack from "@clack/prompts"; import open from "open"; import type { Example } from "./_examples.js"; import { trackRenderFeedback } from "../telemetry/events.js"; import { shouldTrack, flush } from "../telemetry/client.js"; import { getDoctorSummary } from "../telemetry/feedback.js"; import { publishProjectArchive } from "../utils/publishProject.js"; import { submitFeedback } from "../utils/submitFeedback.js"; import { buildIssueUrl, HYPERFRAMES_REPO_URL } from "../utils/feedbackIssue.js"; import { VERSION } from "../version.js"; import { c } from "../ui/colors.js"; export const examples: Example[] = [ ["Submit render feedback", 'hyperframes feedback --rating 4 --comment "fast but font missing"'], ["Quick rating only", "hyperframes feedback --rating 5"], [ "Also file a GitHub issue with a published repro", 'hyperframes feedback --rating 2 --comment "GSAP timeline froze" --file-issue', ], ]; function parseRating(raw: string): number | null { const n = parseInt(raw, 10); return n >= 1 && n <= 5 && Number.isFinite(n) ? n : null; } function normalizeComment(raw?: string): string | undefined { return raw || undefined; } function printIssueConsent(dir: string): void { console.log(); console.log( ` ${c.bold("Filing an issue will publish this project publicly and open a GitHub issue draft.")}`, ); console.log(` ${c.dim(`Project at ${dir} will be uploaded to a public URL.`)}`); console.log( ` ${c.dim("The issue draft will contain that public link plus your feedback; you review and submit it.")}`, ); console.log(); } async function promptConfirm(): Promise { const approved = await clack.confirm({ message: "Publish this project and draft the issue?" }); return !clack.isCancel(approved) && approved === true; } /** * Consent gate: publishing uploads the project to a PUBLIC url, so confirm * before proceeding. Returns true when the caller may publish + file. */ async function confirmFileIssue(dir: string, yes: boolean): Promise { printIssueConsent(dir); if (yes) return true; if (!process.stdout.isTTY) { console.log(` ${c.dim("Re-run with --yes to publish the repro and file the issue.")}\n`); return false; } if (await promptConfirm()) return true; console.log(`\n ${c.dim("Aborted. Feedback was still sent.")}\n`); return false; } /** * Publish a minimal repro and return its public URL. Degrades gracefully: * on failure it returns undefined so the issue still opens without a link. */ async function publishRepro(dir: string): Promise { const spinner = clack.spinner(); spinner.start("Publishing minimal repro..."); try { const published = await publishProjectArchive(dir); spinner.stop(c.success("Repro published")); return published.url; } catch (err: unknown) { spinner.stop(c.error("Publish failed")); console.error(` ${(err as Error).message}`); console.log(` ${c.dim("Filing the issue without a repro link.")}`); return undefined; } } async function openAndPrintIssue(url: string): Promise { if (process.stdout.isTTY) { try { await open(url); } catch { // Headless or no browser; the printed URL below is the fallback. } } console.log(); console.log(` ${c.dim("Review and submit the pre-filled issue (it is not auto-submitted):")}`); console.log(` ${c.accent(url)}`); console.log(); } async function fileGithubIssue(opts: { rating: number; comment?: string; rawDir?: string; yes: boolean; doctorSummary: string; }): Promise { const dir = resolve(opts.rawDir ?? "."); if (!(await confirmFileIssue(dir, opts.yes))) return; const repoPublicUrl = await publishRepro(dir); const url = buildIssueUrl({ repoUrl: HYPERFRAMES_REPO_URL, rating: opts.rating, comment: opts.comment, repoPublicUrl, environment: opts.doctorSummary, cliVersion: VERSION, }); await openAndPrintIssue(url); } export default defineCommand({ meta: { name: "feedback", description: "Submit anonymous feedback about your experience" }, args: { rating: { type: "string", description: "Satisfaction rating (1=poor, 5=great)", required: true, }, comment: { type: "string", description: "Optional details about your experience", }, "file-issue": { type: "boolean", description: "Also open a pre-filled GitHub issue with a published minimal repro", default: false, }, dir: { type: "string", description: "Project directory to publish as the repro (default: current directory)", }, yes: { type: "boolean", alias: "y", description: "Skip the publish + file-issue consent prompt", default: false, }, }, async run({ args }) { const rating = parseRating(args.rating); if (rating === null) { console.error(c.error("Rating must be between 1 and 5")); process.exit(1); } if (!shouldTrack()) { console.log(c.dim("Telemetry is disabled. Feedback not sent.")); return; } const comment = normalizeComment(args.comment); const doctorSummary = await getDoctorSummary(); // The standalone command runs separately from `render`, so it has no real // elapsed time to report. Omit it rather than recording a fake duration. trackRenderFeedback({ rating, comment, doctorSummary }); await flush(); // Ack first so the user isn't kept waiting on the best-effort forward (which // is bounded to a few seconds and never surfaces an error either way). console.log(c.dim("Thanks for the feedback!")); await submitFeedback({ rating, comment, cliVersion: VERSION, env: doctorSummary }); if (args["file-issue"] === true) { await fileGithubIssue({ rating, comment, rawDir: args.dir, yes: args.yes === true, doctorSummary, }); } }, });