diff --git a/README.md b/README.md index 67edbb5..940af9c 100644 --- a/README.md +++ b/README.md @@ -50,23 +50,24 @@ For local report UI development: bun run dev:report-web ``` -## Options +## Usage ```text ---window rolling time window (default 90d, e.g. 30d, 12w) ---out output basename; writes .html and .zip - (default ./patchwave-report) ---include comma-separated repo names to include ---exclude comma-separated repo names to exclude ---help show this help +patchwave-analysis [] + +If is omitted, you are prompted for it. + + --help show this help ``` +The CLI takes a single optional argument — the org or user to scan. There are no other flags; the time window (90 days) and output location are fixed. + ## Output -Each run writes two siblings next to `--out`: +Each run writes two files into a fresh temporary directory and prints the full paths when the scan finishes: -- **`.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up report data in the file. -- **`.zip`** — the same HTML report plus every raw data slice behind it, one JSON file per slice. This is the artifact to send back when you want a deeper look from contextbridge. +- **`patchwave-report.html`** — the self-contained browser report. Open it locally; it embeds the rolled-up report data in the file. +- **`patchwave-report.zip`** — the same HTML report plus every raw data slice behind it, one JSON file per slice. This is the artifact to send back when you want a deeper look from contextbridge. The zip contains: @@ -86,11 +87,11 @@ data/contributors.json — active human committers per repo data/warnings.json — per-collector warnings suppressed during the crawl ``` -Nothing in the report or bundle leaves your machine unless you choose to share it. The archive does not include tokens, secrets, or repository file contents. +Nothing in the report or bundle leaves your machine unless you choose to share it. At the end of a run, you can keep everything local, share only the HTML report, or share the HTML report plus the raw-data zip. The archive does not include tokens, secrets, or repository file contents. ## What it does not do -- It does not upload the report or any GitHub data. It only reads from `api.github.com`. Filesystem writes are limited to the `.html` / `.zip` pair under `--out` and a one-time anonymous-id file (see Telemetry). +- It does not upload the report or any GitHub data unless you choose to share the generated artifacts. It reads from `api.github.com`. Filesystem writes are limited to the `patchwave-report.html` / `patchwave-report.zip` pair in a temporary directory and a one-time anonymous-id file (see Telemetry). - It does not keep a Markdown compatibility report. - It does not auto-update. @@ -98,7 +99,7 @@ Nothing in the report or bundle leaves your machine unless you choose to share i The CLI sends anonymous product analytics (PostHog) to help us understand how it's used. A random UUID is stored at `$XDG_CONFIG_HOME/contextbridge/anonymous_id` or `~/.config/contextbridge/anonymous_id` and shared across contextbridge tools. **Org names, repo names, tokens, and report contents are never sent** — only event counts and timings. -Events captured: `run_started` (window size, whether include/exclude was used), `run_completed` (repo/PR/warning counts and duration), `run_failed` (error kind and duration). +We capture coarse usage events — when a run starts, finishes, or fails, and the choices you make at the share and open prompts — along with aggregate counts (such as repos, PRs, and warnings), durations, and error kinds. Opt out by setting any of: diff --git a/bun.lock b/bun.lock index a59e995..b6f39fd 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "patchwave-analysis", "dependencies": { + "@clack/prompts": "^1.4.0", "@js-temporal/polyfill": "^0.5.1", "@octokit/graphql": "^9.0.3", "@octokit/plugin-retry": "^8.1.0", @@ -12,6 +13,7 @@ "@octokit/rest": "^22.0.1", "fflate": "^0.8.3", "neverthrow": "^8.2.0", + "open": "^11.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-node": "^5.35.1", @@ -94,6 +96,10 @@ "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + "@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="], + + "@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="], + "@contextbridge-ai/eslint-config": ["@contextbridge-ai/eslint-config@0.0.0", "", { "dependencies": { "@eslint/js": "^10.0.1", "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-unused-imports": "^4.4.1", "typescript-eslint": "^8.59.1" }, "peerDependencies": { "eslint": "^10.0.0", "typescript": "^6.0.0" } }, "sha512-5+1HATOHYQNN9xR4wYiRUZ0Qb+km9YdMyr94sWbkVGApCc9zb7BQDsVHSEFjtkHq28NQ9DIRgPeuwCjxFjkngg=="], "@contextbridge-ai/prettier-config": ["@contextbridge-ai/prettier-config@0.0.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-hW6TZzP3PT9Mcj84xdfduwEWW+j/K6rzc5dZqdx9lOzfz7HGRWI26rWG9snNkv49REyGkLW2QvSA5C60utJiiw=="], @@ -482,6 +488,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -510,6 +518,12 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], @@ -570,6 +584,12 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], @@ -606,12 +626,20 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], @@ -682,6 +710,8 @@ "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -716,6 +746,8 @@ "posthog-node": ["posthog-node@5.35.1", "", { "dependencies": { "@posthog/core": "1.29.9" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-F9S3pEIYfGEVjLYIFHKaqfTIhn5IpS02Dkp7C/f1rqr4Z67Iqbt4jbKO8raWsT0veEI3rUp+DKuXLW1hN07FQA=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], @@ -750,6 +782,8 @@ "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -768,6 +802,8 @@ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], @@ -842,6 +878,8 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], diff --git a/package.json b/package.json index fff587f..933fa24 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "vitest": "^4.0.18" }, "dependencies": { + "@clack/prompts": "^1.4.0", "@js-temporal/polyfill": "^0.5.1", "@octokit/graphql": "^9.0.3", "@octokit/plugin-retry": "^8.1.0", @@ -73,6 +74,7 @@ "@octokit/rest": "^22.0.1", "fflate": "^0.8.3", "neverthrow": "^8.2.0", + "open": "^11.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "posthog-node": "^5.35.1", diff --git a/src/BaseIo.ts b/src/BaseIo.ts index 36ce144..735ae12 100644 --- a/src/BaseIo.ts +++ b/src/BaseIo.ts @@ -16,20 +16,24 @@ export interface Io { readonly stderr: Writer; writeStdout(chunk: string): void; writeStderr(chunk: string): void; + isTty(): boolean; } export interface BaseIoOptions { readonly stdout: Writer; readonly stderr: Writer; + readonly isTty?: boolean; } export abstract class BaseIo implements Io { readonly stdout: Writer; readonly stderr: Writer; + readonly #isTty: boolean; protected constructor(options: BaseIoOptions) { this.stdout = options.stdout; this.stderr = options.stderr; + this.#isTty = options.isTty ?? Boolean(options.stdout.isTTY); } writeStdout(chunk: string): void { @@ -39,4 +43,8 @@ export abstract class BaseIo implements Io { writeStderr(chunk: string): void { this.stderr.write(chunk); } + + isTty(): boolean { + return this.#isTty; + } } diff --git a/src/BrowserOpener.test.ts b/src/BrowserOpener.test.ts new file mode 100644 index 0000000..9683faa --- /dev/null +++ b/src/BrowserOpener.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { BrowserOpenerImpl } from './BrowserOpener.ts'; + +describe('BrowserOpenerImpl', () => { + test('calls the injected opener with the target and resolves ok', async () => { + const calls: string[] = []; + const opener = new BrowserOpenerImpl({ + open: (target) => { + calls.push(target); + return Promise.resolve(); + }, + }); + + const result = await opener.open('/tmp/report.html'); + + expect(result.isOk()).toBe(true); + expect(calls).toEqual(['/tmp/report.html']); + }); + + test('maps a rejected open into an open-failed error', async () => { + const opener = new BrowserOpenerImpl({ open: () => Promise.reject(new Error('no browser')) }); + + const result = await opener.open('/tmp/report.html'); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatchObject({ kind: 'open-failed', message: 'no browser' }); + }); +}); diff --git a/src/BrowserOpener.ts b/src/BrowserOpener.ts new file mode 100644 index 0000000..1915c76 --- /dev/null +++ b/src/BrowserOpener.ts @@ -0,0 +1,34 @@ +import { ResultAsync } from 'neverthrow'; +import open from 'open'; +import { toError } from './errors.ts'; + +export type BrowserOpenError = { kind: 'open-failed'; message: string }; + +export interface BrowserOpener { + open(target: string): ResultAsync; +} + +export type OpenFn = (target: string) => Promise; + +export interface BrowserOpenerImplOptions { + readonly open?: OpenFn; +} + +export class BrowserOpenerImpl implements BrowserOpener { + readonly #open: OpenFn; + + constructor(options: BrowserOpenerImplOptions = {}) { + this.#open = options.open ?? open; + } + + open(target: string): ResultAsync { + return ResultAsync.fromPromise( + this.#open(target).then(() => undefined), + (e): BrowserOpenError => ({ kind: 'open-failed', message: toError(e).message }), + ); + } +} + +export function formatBrowserOpenError(err: BrowserOpenError): string { + return `couldn't open your browser: ${err.message}`; +} diff --git a/src/FileSystem.ts b/src/FileSystem.ts index bfd432d..e801f9f 100644 --- a/src/FileSystem.ts +++ b/src/FileSystem.ts @@ -1,11 +1,18 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { ResultAsync } from 'neverthrow'; import { toError } from './errors.ts'; -export type FsError = { kind: 'write-failed'; path: string; message: string }; +export type FsError = + | { kind: 'write-failed'; path: string; message: string } + | { kind: 'temp-dir-failed'; message: string }; export interface FileSystem { writeTextFile(path: string, contents: string): ResultAsync; writeBinaryFile(path: string, contents: Uint8Array): ResultAsync; + /** Create a fresh, uniquely named directory under the OS temp dir and return its path. */ + makeTempDir(prefix: string): ResultAsync; } export class FileSystemImpl implements FileSystem { @@ -17,6 +24,13 @@ export class FileSystemImpl implements FileSystem { return this.write(path, contents); } + makeTempDir(prefix: string): ResultAsync { + return ResultAsync.fromPromise( + mkdtemp(join(tmpdir(), prefix)), + (e): FsError => ({ kind: 'temp-dir-failed', message: toError(e).message }), + ); + } + private write(path: string, contents: string | Uint8Array): ResultAsync { return ResultAsync.fromPromise( Bun.write(path, contents).then(() => undefined), @@ -30,5 +44,10 @@ export class FileSystemImpl implements FileSystem { } export function formatFsError(err: FsError): string { - return `failed to write ${err.path}: ${err.message}`; + switch (err.kind) { + case 'write-failed': + return `failed to write ${err.path}: ${err.message}`; + case 'temp-dir-failed': + return `failed to create a temporary output directory: ${err.message}`; + } } diff --git a/src/IoImpl.ts b/src/IoImpl.ts index 9a195e7..3d36e69 100644 --- a/src/IoImpl.ts +++ b/src/IoImpl.ts @@ -13,7 +13,8 @@ export class IoImpl extends BaseIo { // receives them through ctx.io. /* eslint-disable no-restricted-properties */ const { stdout = process.stdout, stderr = process.stderr } = options; + const isTty = Boolean(process.stdin.isTTY && stdout.isTTY); /* eslint-enable no-restricted-properties */ - super({ stdout, stderr }); + super({ stdout, stderr, isTty }); } } diff --git a/src/cli.test.ts b/src/cli.test.ts index 202ac9d..38f51fd 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -5,27 +5,54 @@ import { createFakeContext } from './testHelpers/index.ts'; test('prints usage and exits 0 when --help is passed', async () => { const { ctx, io } = createFakeContext(); - const code = await main(ctx, ['--help']); - expect(code).toBe(0); + const result = await main(ctx, ['--help']); + expect(result).toMatchObject({ kind: 'usage', code: 0 }); expect(io.stderr.text()).toContain('usage: patchwave-analysis'); }); -test('returns 1 and prints the usage when no target is provided', async () => { +test('prompts for the target when no positional is provided, picking from the listed orgs', async () => { + const { ctx, prompter, githubClient } = createFakeContext(); + githubClient.onRequest('GET /user').resolves({ login: 'ben' }); + githubClient.onPaginate('GET /user/orgs').resolves([{ login: 'acme' }]); + prompter.scriptSelect('acme'); + // The chosen org then drives the real run, which fails the listing — we + // only care that the select fired and that target_prompted is recorded. + githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); + + const result = await main(ctx, []); + + expect(prompter.selects[0]?.choices.map((c) => c.value)).toEqual(['ben', 'acme', '__other__']); + expect(result.kind).toBe('failed'); +}); + +test('cancelling the target prompt returns failed and tells the user why', async () => { + const { ctx, prompter, githubClient } = createFakeContext(); + githubClient.onRequest('GET /user').resolves({ login: 'ben' }); + githubClient.onPaginate('GET /user/orgs').resolves([]); + prompter.scriptSelect({ kind: 'cancelled' }); + + const result = await main(ctx, []); + + expect(result).toMatchObject({ kind: 'failed', code: 1 }); + expect(prompter.errors[0]).toContain('cancelled by user'); +}); + +test('rejects unknown flags', async () => { const { ctx, io } = createFakeContext(); - const code = await main(ctx, []); - expect(code).toBe(1); - expect(io.stderr.text()).toContain('missing required argument'); + const result = await main(ctx, ['acme', '--window', '30d']); + expect(result).toMatchObject({ kind: 'usage', code: 1 }); + expect(io.stderr.text()).toContain('failed to parse arguments'); }); -test('rejects an invalid --window value', async () => { +test('rejects more than one positional argument', async () => { const { ctx, io } = createFakeContext(); - const code = await main(ctx, ['acme', '--window', 'foobar']); - expect(code).toBe(1); - expect(io.stderr.text()).toContain('invalid --window'); + const result = await main(ctx, ['acme', 'globex']); + expect(result).toMatchObject({ kind: 'usage', code: 1 }); + expect(io.stderr.text()).toContain('expected a single org or user'); }); test('writes a report when the GitHub calls succeed', async () => { - const { ctx, io, githubClient, fs, analytics } = createFakeContext(); + const { ctx, githubClient, fs, analytics } = createFakeContext(); githubClient.onPaginate('GET /orgs/{org}/repos', {}).resolves([ { @@ -54,12 +81,17 @@ test('writes a report when the GitHub calls succeed', async () => { search: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, }); - const code = await main(ctx, ['acme', '--out', '/tmp/report']); - expect(code).toBe(0); - expect(io.stdout.text()).toContain('wrote /tmp/report.html'); - expect(io.stdout.text()).toContain('wrote /tmp/report.zip'); + const result = await main(ctx, ['acme']); + expect(result.kind).toBe('completed'); + if (result.kind !== 'completed') return; + expect(result.code).toBe(0); + + // Output lands in a temp dir, not the CWD; locate it via the returned paths. + expect(result.run.target).toBe('acme'); + expect(result.run.paths.html.endsWith('patchwave-report.html')).toBe(true); + expect(result.run.paths.zip.endsWith('patchwave-report.zip')).toBe(true); - const written = fs.read('/tmp/report.html'); + const written = fs.read(result.run.paths.html); expect(written).toBeDefined(); expect(written).toContain('([\s\S]*?)<\/script>/.exec(written ?? ''); @@ -67,7 +99,7 @@ test('writes a report when the GitHub calls succeed', async () => { const embedded = JSON.parse(match?.[1] ?? '') as { meta: { org: string } }; expect(embedded.meta.org).toBe('acme'); - const zipBytes = fs.readBinary('/tmp/report.zip'); + const zipBytes = fs.readBinary(result.run.paths.zip); expect(zipBytes).toBeInstanceOf(Uint8Array); const entries = unzipSync(zipBytes as Uint8Array); expect(Object.keys(entries).sort()).toEqual( @@ -96,10 +128,16 @@ test('writes a report when the GitHub calls succeed', async () => { expect(repos).toHaveLength(1); expect(repos[0]?.name).toBe('widgets'); + // The completed run hands the bytes back so the caller (index.ts) can drive + // the share prompt without re-reading the filesystem. + expect(result.run.zipBytes).toBeInstanceOf(Uint8Array); + expect(result.run.html).toContain(' { expect(failed?.properties).toMatchObject({ error_kind: 'forbidden' }); }); -test('exits 1 when listOrgRepos fails non-recoverably', async () => { - const { ctx, io, githubClient } = createFakeContext(); +test('returns failed when listOrgRepos fails non-recoverably', async () => { + const { ctx, prompter, githubClient } = createFakeContext(); githubClient.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); - const code = await main(ctx, ['acme']); - expect(code).toBe(1); - expect(io.stderr.text()).toContain('403'); + const result = await main(ctx, ['acme']); + expect(result).toMatchObject({ kind: 'failed', code: 1 }); + expect(prompter.errors[0]).toContain('403'); +}); + +test('returns failed when the temp output directory cannot be created', async () => { + const { ctx, fs, prompter, analytics } = createFakeContext(); + fs.failNextTempDirWith({ kind: 'temp-dir-failed', message: 'disk full' }); + + const result = await main(ctx, ['acme']); + + expect(result).toMatchObject({ kind: 'failed', code: 1 }); + expect(prompter.errors[0]).toContain('temporary output directory'); + expect(analytics.capturedEvents('run_failed')[0]?.properties).toMatchObject({ error_kind: 'temp-dir-failed' }); }); diff --git a/src/cli.ts b/src/cli.ts index 828733b..5050239 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ +import { join } from 'node:path'; import { parseArgs } from 'node:util'; import { Result, ResultAsync } from 'neverthrow'; -import pkg from '../package.json' with { type: 'json' }; import { getBranchProtection } from './collectors/branchProtection.ts'; import { listActiveCommitters } from './collectors/contributors.ts'; import { getCveAlerts } from './collectors/cve.ts'; @@ -12,6 +12,8 @@ import type { Context } from './context.ts'; import { getErrorMessage } from './errors.ts'; import { formatFsError } from './FileSystem.ts'; import { type GithubError, formatGithubError } from './github/errors.ts'; +import { promptForTarget } from './interactive/targetPrompt.ts'; +import { formatPromptError } from './prompt/Prompter.ts'; import { type ReportBundle, aggregate } from './report/aggregate.ts'; import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './report/bundle.ts'; import { type RenderError, renderHtml } from './report/html.ts'; @@ -28,12 +30,25 @@ import type { RepoMeta, } from './types.ts'; +// The CLI is intentionally flag-free (besides --help): the rolling window, output +// location, and repo filtering are fixed for now. These live as constants so the +// pipeline below — and the bundle metadata format — stays unchanged if we re-add +// flags later. +const WINDOW_DAYS = 90; +const OUTPUT_BASENAME = 'patchwave-report'; +const TEMP_DIR_PREFIX = 'patchwave-analysis-'; + export interface CliOptions { - target: string; - windowDays: number; - outBase: string; - include: string[] | null; - exclude: string[]; + /** `null` means the user didn't pass a positional target — `main()` will prompt for one. */ + readonly target: string | null; +} + +interface ResolvedOptions { + readonly target: string; + readonly windowDays: number; + readonly outBase: string; + readonly include: string[] | null; + readonly exclude: string[]; } export interface OutputPaths { @@ -45,32 +60,79 @@ export function resolveOutputPaths(outBase: string): OutputPaths { return { html: `${outBase}.html`, zip: `${outBase}.zip` }; } -export async function main(ctx: Context, argv: readonly string[]): Promise { +export interface CompletedRun { + readonly target: string; + readonly paths: OutputPaths; + readonly html: string; + readonly zipBytes: Uint8Array; +} + +export type MainResult = + | { kind: 'usage'; code: number } + | { kind: 'failed'; code: number } + | { kind: 'completed'; code: 0; run: CompletedRun }; + +export async function main(ctx: Context, argv: readonly string[]): Promise { const parsed = parseCli(argv); if (parsed.kind === 'err') { if (parsed.message.length > 0) ctx.logger.error(parsed.message); // usage text is a multi-line reference document, not a log line — bypass pino. ctx.io.writeStderr(`${usage()}\n`); - return parsed.message.length > 0 ? 1 : 0; + return { kind: 'usage', code: parsed.message.length > 0 ? 1 : 0 }; } - const opts = parsed.value; - + const flagOpts = parsed.value; const startedAt = ctx.clock.now(); + + let target: string; + if (flagOpts.target === null) { + const targetResult = await promptForTarget({ prompter: ctx.prompter, githubClient: ctx.githubClient }); + if (targetResult.isErr()) { + ctx.prompter.error(`couldn't read target: ${formatPromptError(targetResult.error)}`); + return { kind: 'failed', code: 1 }; + } + target = targetResult.value; + } else { + target = flagOpts.target; + } + + const tempDirResult = await ctx.fs.makeTempDir(TEMP_DIR_PREFIX); + if (tempDirResult.isErr()) { + ctx.prompter.error(formatFsError(tempDirResult.error)); + ctx.analytics.capture('run_failed', { + error_kind: tempDirResult.error.kind, + duration_ms: elapsedMs(startedAt, ctx.clock.now()), + }); + return { kind: 'failed', code: 1 }; + } + + const opts: ResolvedOptions = { + target, + windowDays: WINDOW_DAYS, + outBase: join(tempDirResult.value, OUTPUT_BASENAME), + include: null, + exclude: [], + }; + ctx.analytics.capture('run_started', { window_days: opts.windowDays, has_include: opts.include !== null, has_exclude: opts.exclude.length > 0, + target_prompted: flagOpts.target === null, }); + const spinner = ctx.prompter.spinner(); + spinner.start(`Scanning ${opts.target} (last ${opts.windowDays} days)...`); + const renderResult = await renderReport(ctx, opts); if (renderResult.isErr()) { const error = renderResult.error; - ctx.logger.error(error.kind === 'missing-placeholder' ? error.message : formatGithubError(error)); + spinner.stop('Scan failed.'); + ctx.prompter.error(error.kind === 'missing-placeholder' ? error.message : formatGithubError(error)); ctx.analytics.capture('run_failed', { error_kind: error.kind, duration_ms: elapsedMs(startedAt, ctx.clock.now()), }); - return 1; + return { kind: 'failed', code: 1 }; } const { report, collected, aggregated, stats } = renderResult.value; @@ -78,28 +140,32 @@ export async function main(ctx: Context, argv: readonly string[]): Promise { +function renderReport(ctx: Context, opts: ResolvedOptions): ResultAsync { ctx.logger.info( { target: opts.target, windowDays: opts.windowDays }, `scanning ${opts.target} (${opts.windowDays}-day window)`, @@ -292,10 +363,6 @@ const safeParseArgs = Result.fromThrowable( args: [...argv], allowPositionals: true, options: { - window: { type: 'string', default: '90d' }, - out: { type: 'string', default: './patchwave-report' }, - include: { type: 'string' }, - exclude: { type: 'string' }, help: { type: 'boolean', default: false }, }, }), @@ -305,53 +372,23 @@ const safeParseArgs = Result.fromThrowable( export function parseCli(argv: readonly string[]): ParseCliResult { const parseResult = safeParseArgs(argv); if (parseResult.isErr()) { + // strict parseArgs throws on any unknown --flag; surface it as a usage error. return { kind: 'err', message: `failed to parse arguments: ${parseResult.error}` }; } const parsed = parseResult.value; if (parsed.values.help) return { kind: 'err', message: '' }; - const positional = parsed.positionals; - const target = positional[0]; - if (target === undefined) { - return { kind: 'err', message: 'missing required argument: ' }; - } - const windowDays = parseWindow(parsed.values.window); - if (windowDays === null) { - return { kind: 'err', message: `invalid --window: expected formats like '90d' or '12w'` }; + if (parsed.positionals.length > 1) { + return { + kind: 'err', + message: `expected a single org or user to scan, but got ${parsed.positionals.length}: ${parsed.positionals.join(' ')}`, + }; } - return { - kind: 'ok', - value: { - target, - windowDays, - outBase: normalizeOutBase(parsed.values.out), - include: parsed.values.include ? splitCsv(parsed.values.include) : null, - exclude: parsed.values.exclude ? splitCsv(parsed.values.exclude) : [], - }, - }; + return { kind: 'ok', value: { target: parsed.positionals[0] ?? null } }; } -function normalizeOutBase(out: string): string { - return out.replace(/\.(html|md|zip)$/i, ''); -} - -function parseWindow(s: string): number | null { - const match = /^(\d+)([dw])$/.exec(s.trim()); - if (!match) return null; - const n = Number.parseInt(match[1] ?? '', 10); - if (!Number.isFinite(n) || n <= 0) return null; - return match[2] === 'w' ? n * 7 : n; -} - -function splitCsv(s: string): string[] { - return s - .split(',') - .map((v) => v.trim()) - .filter((v) => v.length > 0); -} - -function filterRepos(repos: RepoMeta[], opts: CliOptions): RepoMeta[] { +function filterRepos(repos: RepoMeta[], opts: ResolvedOptions): RepoMeta[] { let out = repos.filter((r) => !r.archived); const includeSet = opts.include === null ? null : new Set(opts.include); const excludeSet = new Set(opts.exclude); @@ -363,14 +400,14 @@ function filterRepos(repos: RepoMeta[], opts: CliOptions): RepoMeta[] { function usage(): string { return [ '', - 'usage: patchwave-analysis [options]', + 'usage: patchwave-analysis []', + '', + 'If is omitted, you will be prompted for it.', + '', + 'The report (a .html file and a .zip data bundle) is written to a temporary', + 'directory; the paths are printed when the scan finishes.', '', 'options:', - ' --window rolling time window (default 90d)', - ' --out output basename; writes .html and .zip', - ' (default ./patchwave-report)', - ' --include comma-separated repo names to include', - ' --exclude comma-separated repo names to exclude', ' --help show this help', ].join('\n'); } diff --git a/src/context.ts b/src/context.ts index b17beae..7146baf 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,6 +1,8 @@ import type { Analytics } from './Analytics.ts'; import { NoopAnalytics } from './Analytics.ts'; import type { Io } from './BaseIo.ts'; +import type { BrowserOpener } from './BrowserOpener.ts'; +import { BrowserOpenerImpl } from './BrowserOpener.ts'; import type { Clock } from './Clock.ts'; import { ClockImpl } from './Clock.ts'; import { type Environment, getEnvironment } from './environment.ts'; @@ -10,6 +12,10 @@ import type { GithubClient } from './github/GithubClient.ts'; import { GithubClientImpl } from './github/GithubClient.ts'; import { IoImpl } from './IoImpl.ts'; import { type Logger, createLogger } from './logger.ts'; +import type { Prompter } from './prompt/Prompter.ts'; +import { PrompterImpl } from './prompt/Prompter.ts'; +import type { Uploader } from './upload/Uploader.ts'; +import { UploaderImpl } from './upload/Uploader.ts'; export interface Context { readonly io: Io; @@ -19,10 +25,15 @@ export interface Context { readonly fs: FileSystem; readonly githubClient: GithubClient; readonly analytics: Analytics; + readonly prompter: Prompter; + readonly uploader: Uploader; + readonly browserOpener: BrowserOpener; + readonly appVersion: string; } export interface CreateContextOptions { readonly token: string; + readonly appVersion: string; readonly io?: Io; readonly logger?: Logger; readonly env?: Environment; @@ -30,18 +41,25 @@ export interface CreateContextOptions { readonly fs?: FileSystem; readonly githubClient?: GithubClient; readonly analytics?: Analytics; + readonly prompter?: Prompter; + readonly uploader?: Uploader; + readonly browserOpener?: BrowserOpener; } export function createContext(options: CreateContextOptions): Context { const { token, + appVersion, io = new IoImpl(), env = getEnvironment(), clock = new ClockImpl(), fs = new FileSystemImpl(), analytics = new NoopAnalytics(), + prompter = new PrompterImpl(), + uploader = new UploaderImpl(), + browserOpener = new BrowserOpenerImpl(), } = options; const logger = options.logger ?? createLogger({ level: env.LOG_LEVEL, destination: io.stderr }); const githubClient = options.githubClient ?? new GithubClientImpl({ token, logger }); - return { io, logger, env, clock, fs, githubClient, analytics }; + return { io, logger, env, clock, fs, githubClient, analytics, prompter, uploader, browserOpener, appVersion }; } diff --git a/src/github/GithubClient.ts b/src/github/GithubClient.ts index 4bc75f0..8bd8a7f 100644 --- a/src/github/GithubClient.ts +++ b/src/github/GithubClient.ts @@ -8,8 +8,6 @@ import { type GithubError, toGithubError } from './errors.ts'; const PatchwaveOctokit = Octokit.plugin(retry, throttling); -function noop(): void {} - /** * Narrow GitHub API surface the collectors depend on. Each method returns a * ResultAsync so callers can chain without try/catch. Production wires this to @@ -33,15 +31,16 @@ export class GithubClientImpl implements GithubClient { constructor(options: GithubClientImplOptions) { const { token, logger, userAgent = 'patchwave-analysis' } = options; + // `@octokit/request` emits endpoint deprecation notices via `request.log.warn`, + // which defaults to `console` and so bypasses the top-level `log` below. Pass + // our logger as `request.log` on each client too, so that noise lands in pino + // (silent by default) instead of the user's terminal. A child tags the source. + const log = logger.child({ source: 'octokit' }); this.rest = new PatchwaveOctokit({ auth: token, userAgent, - log: { - debug: noop, - info: noop, - warn: noop, - error: (msg: string) => logger.error({ source: 'octokit' }, msg), - }, + log, + request: { log }, retry: { doNotRetry: [400, 401, 403, 404, 409, 422] }, throttle: { onRateLimit: (_retryAfter, _opts, _octokit, retryCount) => retryCount < 2, @@ -50,6 +49,7 @@ export class GithubClientImpl implements GithubClient { }); this.graphqlClient = graphqlBase.defaults({ headers: { authorization: `token ${token}` }, + request: { log }, }); } diff --git a/src/index.ts b/src/index.ts index ecd15e6..b9c26ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,41 +2,83 @@ import pkg from '../package.json' with { type: 'json' }; import { type Analytics, AnalyticsImpl, NoopAnalytics } from './Analytics.ts'; import { getOrCreateAnonymousId } from './anonymousId.ts'; -import { main } from './cli.ts'; +import { main, parseCli } from './cli.ts'; import { createContext } from './context.ts'; import { getEnvironment, isTelemetryDisabled } from './environment.ts'; -import { formatAuthError, resolveToken } from './github/auth.ts'; +import { welcomeBannerBody, welcomeBannerTitle } from './interactive/banner.ts'; +import { runOpenReportPrompt } from './interactive/openReportPrompt.ts'; +import { runSharePrompt } from './interactive/sharePrompt.ts'; +import { formatInteractiveTokenError, interactiveResolveToken } from './interactive/tokenWalkthrough.ts'; +import { enforceTty } from './interactive/ttyGate.ts'; import { IoImpl } from './IoImpl.ts'; import { createLogger } from './logger.ts'; +import { PrompterImpl } from './prompt/Prompter.ts'; +import { UploaderImpl } from './upload/Uploader.ts'; const io = new IoImpl(); const env = getEnvironment(); -const logger = createLogger({ level: env.LOG_LEVEL, destination: io.stderr }); -const argv = process.argv.slice(2); -const isHelp = argv.includes('--help'); -const telemetryDisabled = isHelp || isTelemetryDisabled(env); +// In interactive mode we let Clack own the visual surface — pino chatter would +// interleave with prompts and spinners. Default to `silent` unless the user +// opted in explicitly with LOG_LEVEL. +const explicitLogLevel = process.env['LOG_LEVEL'] !== undefined; +const logLevel = explicitLogLevel ? env.LOG_LEVEL : 'silent'; +const logger = createLogger({ level: logLevel, destination: io.stderr }); + +const ttyGate = enforceTty(io); +if (!ttyGate.ok) process.exit(ttyGate.code); + +const telemetryDisabled = isTelemetryDisabled(env); const distinctId = telemetryDisabled ? '' : getOrCreateAnonymousId(env); const analytics: Analytics = telemetryDisabled ? new NoopAnalytics() : new AnalyticsImpl({ distinctId, version: pkg.version }); if (!telemetryDisabled) analytics.identify(distinctId); -if (isHelp) { - const ctx = createContext({ token: '', io, env, logger, analytics }); - const code = await main(ctx, argv); - await analytics.shutdown(); - process.exit(code); +const argv = process.argv.slice(2); +const prompter = new PrompterImpl(); +const isFullRun = parseCli(argv).kind === 'ok'; + +let token = ''; +if (isFullRun) { + prompter.intro(welcomeBannerTitle()); + prompter.note(welcomeBannerBody(), 'What this is'); + + const tokenResult = await interactiveResolveToken({ prompter }); + if (tokenResult.isErr()) { + prompter.error(formatInteractiveTokenError(tokenResult.error)); + await analytics.shutdown(); + process.exit(1); + } + token = tokenResult.value; } -const tokenResult = await resolveToken(); -if (tokenResult.isErr()) { - logger.error(formatAuthError(tokenResult.error)); - await analytics.shutdown(); - process.exit(1); +const ctx = createContext({ + token, + appVersion: pkg.version, + io, + env, + logger, + analytics, + prompter, + uploader: new UploaderImpl(), +}); + +const result = await main(ctx, argv); + +if (result.kind === 'completed') { + const identifier = distinctId.length > 0 ? distinctId : 'anonymous'; + await runOpenReportPrompt({ context: ctx, htmlPath: result.run.paths.html }); + await runSharePrompt({ + context: ctx, + target: result.run.target, + htmlPath: result.run.paths.html, + zipPath: result.run.paths.zip, + htmlContent: result.run.html, + zipBytes: result.run.zipBytes, + identifier, + }); } -const ctx = createContext({ token: tokenResult.value, io, env, logger, analytics }); -const code = await main(ctx, argv); await analytics.shutdown(); -process.exit(code); +process.exit(result.code); diff --git a/src/interactive/banner.test.ts b/src/interactive/banner.test.ts new file mode 100644 index 0000000..8fe5b27 --- /dev/null +++ b/src/interactive/banner.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'bun:test'; +import { welcomeBannerBody, welcomeBannerTitle } from './banner.ts'; + +test('title names the tool', () => { + expect(welcomeBannerTitle()).toBe('patchwave-analysis'); +}); + +test('body explains the executive summary, points at PatchWave, and is honest about egress', () => { + const body = welcomeBannerBody(); + expect(body).toContain('Dependabot'); + expect(body).toContain('executive summary'); + expect(body).toContain('PatchWave'); + expect(body).toContain('patchwave.ai'); + expect(body).toContain('GitHub'); + expect(body).toContain('choose whether to send us'); +}); diff --git a/src/interactive/banner.ts b/src/interactive/banner.ts new file mode 100644 index 0000000..4fdd0ac --- /dev/null +++ b/src/interactive/banner.ts @@ -0,0 +1,15 @@ +export function welcomeBannerTitle(): string { + return 'patchwave-analysis'; +} + +export function welcomeBannerBody(): string { + return [ + "I'll scan a GitHub org and hand you back an executive summary of how", + 'Dependabot is doing there: coverage, PR backlog, CVE exposure, what the', + 'manual triage is costing you in engineer-hours, and how PatchWave', + '(patchwave.ai) can help.', + '', + 'It reads from GitHub via the API, and you choose whether to send us your', + 'data at the end.', + ].join('\n'); +} diff --git a/src/interactive/openReportPrompt.test.ts b/src/interactive/openReportPrompt.test.ts new file mode 100644 index 0000000..2ef355e --- /dev/null +++ b/src/interactive/openReportPrompt.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; +import { createFakeContext } from '../testHelpers/createFakeContext.ts'; +import { runOpenReportPrompt } from './openReportPrompt.ts'; + +describe('runOpenReportPrompt', () => { + test('defaults the confirm to yes', async () => { + const handle = createFakeContext(); + handle.prompter.scriptConfirm(true); + + await runOpenReportPrompt({ context: handle.ctx, htmlPath: '/tmp/report.html' }); + + expect(handle.prompter.confirms[0]?.defaultValue).toBe(true); + }); + + test('opens the report in the browser when confirmed', async () => { + const handle = createFakeContext(); + handle.prompter.scriptConfirm(true); + + await runOpenReportPrompt({ context: handle.ctx, htmlPath: '/tmp/report.html' }); + + expect(handle.browserOpener.opened).toEqual(['/tmp/report.html']); + expect(handle.analytics.capturedEvents('report_open_choice')[0]?.properties).toMatchObject({ opened: true }); + }); + + test('does not open when declined', async () => { + const handle = createFakeContext(); + handle.prompter.scriptConfirm(false); + + await runOpenReportPrompt({ context: handle.ctx, htmlPath: '/tmp/report.html' }); + + expect(handle.browserOpener.opened).toHaveLength(0); + expect(handle.analytics.capturedEvents('report_open_choice')[0]?.properties).toMatchObject({ opened: false }); + }); + + test('treats a cancelled prompt as no', async () => { + const handle = createFakeContext(); + handle.prompter.scriptConfirm({ kind: 'cancelled' }); + + await runOpenReportPrompt({ context: handle.ctx, htmlPath: '/tmp/report.html' }); + + expect(handle.browserOpener.opened).toHaveLength(0); + expect(handle.analytics.capturedEvents('report_open_choice')[0]?.properties).toMatchObject({ opened: false }); + }); + + test('warns and points at the file when the browser fails to open', async () => { + const handle = createFakeContext(); + handle.prompter.scriptConfirm(true); + handle.browserOpener.fails({ kind: 'open-failed', message: 'no display' }); + + await runOpenReportPrompt({ context: handle.ctx, htmlPath: '/tmp/report.html' }); + + expect(handle.prompter.warns[0]).toContain('no display'); + expect(handle.prompter.warns[0]).toContain('/tmp/report.html'); + }); +}); diff --git a/src/interactive/openReportPrompt.ts b/src/interactive/openReportPrompt.ts new file mode 100644 index 0000000..e8c243f --- /dev/null +++ b/src/interactive/openReportPrompt.ts @@ -0,0 +1,31 @@ +import { formatBrowserOpenError } from '../BrowserOpener.ts'; +import type { Context } from '../context.ts'; + +export interface OpenReportPromptInputs { + readonly context: Context; + readonly htmlPath: string; +} + +/** + * Offer to open the freshly generated report in the user's browser. Defaults to + * "yes". Shown before the share prompt so they can eyeball the report before + * deciding whether to send it. A cancel or prompt error is treated as "no", and + * a failed launch degrades to pointing at the file on disk — neither aborts the run. + */ +export async function runOpenReportPrompt(inputs: OpenReportPromptInputs): Promise { + const { prompter, analytics, browserOpener } = inputs.context; + + const choice = await prompter.confirm({ + message: 'Open the report in your browser?', + defaultValue: true, + }); + + const shouldOpen = choice.unwrapOr(false); + analytics.capture('report_open_choice', { opened: shouldOpen }); + if (!shouldOpen) return; + + const opened = await browserOpener.open(inputs.htmlPath); + if (opened.isErr()) { + prompter.warn(`${formatBrowserOpenError(opened.error)} Open it yourself: ${inputs.htmlPath}`); + } +} diff --git a/src/interactive/sharePrompt.test.ts b/src/interactive/sharePrompt.test.ts new file mode 100644 index 0000000..2453fb6 --- /dev/null +++ b/src/interactive/sharePrompt.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'bun:test'; +import { fakeContextHandle } from '../testHelpers/testFactories.ts'; +import { runSharePrompt } from './sharePrompt.ts'; +import { sharePromptInputsFor } from './testFactories.ts'; + +describe('runSharePrompt', () => { + test('shows file paths and a clear share question, defaulting to HTML only', async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect('declined'); + + await runSharePrompt(sharePromptInputsFor(handle)); + + const reportReadyNote = handle.prompter.notes.find((n) => n.title === 'Report ready'); + expect(reportReadyNote?.message).toContain('acme'); + expect(reportReadyNote?.message).toContain('/tmp/report.html'); + expect(reportReadyNote?.message).toContain('/tmp/report.zip'); + expect(handle.prompter.selects[0]?.message).toContain('share this with us'); + expect(handle.prompter.selects[0]?.choices.map((c) => c.value)).toEqual(['full', 'html', 'declined']); + expect(handle.prompter.selects[0]?.initialValue).toBe('html'); + }); + + test("declined: doesn't upload, points to founders + patchwave.ai", async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect('declined'); + + const outcome = await runSharePrompt(sharePromptInputsFor(handle)); + + expect(outcome).toEqual({ kind: 'declined' }); + expect(handle.uploader.calls).toHaveLength(0); + const allSetNote = handle.prompter.notes.find((n) => n.title === 'All set'); + expect(allSetNote?.message).toContain('founders@contextbridge.ai'); + expect(allSetNote?.message).toContain('patchwave.ai'); + expect(handle.analytics.capturedEvents('share_choice')[0]?.properties).toMatchObject({ choice: 'declined' }); + }); + + test('html-only: uploads the raw html bytes with kind:html', async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect('html').scriptText(''); + + const outcome = await runSharePrompt(sharePromptInputsFor(handle)); + + expect(outcome).toMatchObject({ kind: 'shared', choice: 'html', identifier: 'anon-uuid' }); + expect(handle.uploader.calls).toHaveLength(1); + expect(handle.uploader.calls[0]).toMatchObject({ + kind: 'html', + identifier: 'anon-uuid', + appVersion: '0.0.1', + timestamp: '2026-05-22T12:00:00Z', + }); + expect(new TextDecoder().decode(handle.uploader.calls[0]?.bytes)).toBe(''); + expect(handle.analytics.capturedEvents('upload_succeeded')[0]?.properties).toMatchObject({ mode: 'html' }); + }); + + test('full: uploads the original zip bytes unchanged with kind:zip', async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect('full').scriptText(''); + const zipBytes = new Uint8Array([1, 2, 3, 4, 5]); + + await runSharePrompt(sharePromptInputsFor(handle, { zipBytes })); + + expect(handle.uploader.calls[0]?.kind).toBe('zip'); + expect(handle.uploader.calls[0]?.bytes).toEqual(zipBytes); + }); + + test('uses a volunteered email as the identifier', async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect('full').scriptText('ben@example.com'); + + const outcome = await runSharePrompt(sharePromptInputsFor(handle)); + + expect(outcome).toMatchObject({ kind: 'shared', identifier: 'ben@example.com' }); + expect(handle.uploader.calls[0]?.identifier).toBe('ben@example.com'); + }); + + test('upload failure surfaces the error and leaves files in place', async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect('full').scriptText(''); + handle.uploader.fails({ kind: 'presign-bad-status', status: 500, body: 'boom' }); + + const outcome = await runSharePrompt(sharePromptInputsFor(handle)); + + expect(outcome.kind).toBe('upload-failed'); + if (outcome.kind === 'upload-failed') { + expect(outcome.choice).toBe('full'); + expect(outcome.message).toContain('500'); + } + expect(handle.analytics.capturedEvents('upload_failed')[0]?.properties).toMatchObject({ + mode: 'full', + error_kind: 'presign-bad-status', + }); + const failureNote = handle.prompter.notes.find((n) => n.title === "We couldn't upload"); + expect(failureNote?.message).toContain('/tmp/report.html'); + expect(failureNote?.message).toContain('/tmp/report.zip'); + expect(failureNote?.message).toContain('founders@contextbridge.ai'); + }); + + test('user cancellation at the choice prompt is treated as declined', async () => { + const handle = fakeContextHandle.build(); + handle.prompter.scriptSelect({ kind: 'cancelled' }); + + const outcome = await runSharePrompt(sharePromptInputsFor(handle)); + + expect(outcome).toEqual({ kind: 'cancelled' }); + expect(handle.uploader.calls).toHaveLength(0); + expect(handle.analytics.capturedEvents('share_choice')[0]?.properties).toMatchObject({ choice: 'cancelled' }); + }); +}); diff --git a/src/interactive/sharePrompt.ts b/src/interactive/sharePrompt.ts new file mode 100644 index 0000000..85afdf4 --- /dev/null +++ b/src/interactive/sharePrompt.ts @@ -0,0 +1,143 @@ +import type { Context } from '../context.ts'; +import { type Prompter, formatPromptError } from '../prompt/Prompter.ts'; +import { type BundleKind, formatUploadError } from '../upload/Uploader.ts'; + +const SUPPORT_LINE = 'Reach us at founders@contextbridge.ai — or learn more at https://patchwave.ai'; + +export type ShareChoice = 'html' | 'full' | 'declined'; + +export interface SharePromptInputs { + readonly context: Context; + readonly target: string; + readonly htmlPath: string; + readonly zipPath: string; + readonly htmlContent: string; + readonly zipBytes: Uint8Array; + readonly identifier: string; +} + +export type ShareOutcome = + | { kind: 'shared'; choice: 'html' | 'full'; uploadId: string; identifier: string } + | { kind: 'declined' } + | { kind: 'cancelled' } + | { kind: 'upload-failed'; choice: 'html' | 'full'; message: string }; + +export async function runSharePrompt(inputs: SharePromptInputs): Promise { + const { prompter, analytics, uploader } = inputs.context; + + prompter.note( + [ + `Scanned: ${inputs.target}`, + `HTML report: ${inputs.htmlPath}`, + `Raw data bundle: ${inputs.zipPath}`, + '', + "Open either file to see what would be sent — we'll upload exactly what's on disk.", + ].join('\n'), + 'Report ready', + ); + + analytics.capture('share_prompt_shown', {}); + + const choiceResult = await prompter.select({ + message: "Would you like to share this with us? We won't share your data with anyone.", + initialValue: 'html', + choices: [ + { value: 'full', label: 'Share the HTML report + raw data', hint: 'the full .zip you saw above' }, + { value: 'html', label: 'Share the HTML report only', hint: 'the .html, no raw data' }, + { value: 'declined', label: 'No thanks — keep it local', hint: 'nothing leaves your machine' }, + ], + }); + + if (choiceResult.isErr()) { + analytics.capture('share_choice', { choice: 'cancelled' }); + if (choiceResult.error.kind !== 'cancelled') prompter.warn(formatPromptError(choiceResult.error)); + declinedOutro(inputs); + return { kind: 'cancelled' }; + } + + const choice = choiceResult.value; + analytics.capture('share_choice', { choice }); + + if (choice === 'declined') { + declinedOutro(inputs); + return { kind: 'declined' }; + } + + const identifierResult = await maybeAskForEmail(prompter, inputs.identifier); + if (identifierResult.kind === 'cancelled') { + declinedOutro(inputs); + return { kind: 'cancelled' }; + } + const identifier = identifierResult.identifier; + + const kind: BundleKind = choice === 'html' ? 'html' : 'zip'; + const bytes = choice === 'html' ? new TextEncoder().encode(inputs.htmlContent) : inputs.zipBytes; + + const spinner = prompter.spinner(); + spinner.start('Uploading...'); + const uploadResult = await uploader.upload({ + bytes, + kind, + identifier, + appVersion: inputs.context.appVersion, + timestamp: inputs.context.clock.now().toString(), + }); + + if (uploadResult.isErr()) { + const message = formatUploadError(uploadResult.error); + spinner.stop('Upload failed.'); + analytics.capture('upload_failed', { mode: choice, error_kind: uploadResult.error.kind }); + prompter.error(message); + prompter.note( + [`Your local files are unchanged:`, ` ${inputs.htmlPath}`, ` ${inputs.zipPath}`, '', SUPPORT_LINE].join('\n'), + "We couldn't upload", + ); + return { kind: 'upload-failed', choice, message }; + } + + const { uploadId } = uploadResult.value; + spinner.stop('Uploaded.'); + analytics.capture('upload_succeeded', { mode: choice }); + prompter.outro(`Thanks for sharing! Upload id: ${uploadId}. We'll be in touch if anything jumps out.`); + return { kind: 'shared', choice, uploadId, identifier }; +} + +function declinedOutro(inputs: SharePromptInputs): void { + const { prompter } = inputs.context; + prompter.note( + [ + `No worries — nothing was uploaded. Your report lives here:`, + ` ${inputs.htmlPath}`, + ` ${inputs.zipPath}`, + '', + `Want help cutting your Dependabot burden? ${SUPPORT_LINE}`, + ].join('\n'), + 'All set', + ); + prompter.outro('Done.'); +} + +async function maybeAskForEmail( + prompter: Prompter, + fallbackIdentifier: string, +): Promise<{ kind: 'ok'; identifier: string } | { kind: 'cancelled' }> { + const result = await prompter.text({ + message: 'Email (optional, so we can follow up):', + placeholder: 'leave blank to stay anonymous', + defaultValue: '', + validate: (value) => { + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed) ? undefined : "that doesn't look like an email address"; + }, + }); + + if (result.isErr()) { + if (result.error.kind === 'cancelled') return { kind: 'cancelled' }; + prompter.warn(formatPromptError(result.error)); + return { kind: 'ok', identifier: fallbackIdentifier }; + } + + const trimmed = result.value.trim(); + return { kind: 'ok', identifier: trimmed.length > 0 ? trimmed : fallbackIdentifier }; +} diff --git a/src/interactive/targetPrompt.test.ts b/src/interactive/targetPrompt.test.ts new file mode 100644 index 0000000..5db24dc --- /dev/null +++ b/src/interactive/targetPrompt.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test'; +import { FakeGithubClient, FakePrompter } from '../testHelpers/index.ts'; +import { promptForTarget } from './targetPrompt.ts'; +import { githubOrg, githubViewer } from './testFactories.ts'; + +function stubViewer(githubClient: FakeGithubClient, login: string): void { + githubClient.onRequest('GET /user').resolves(githubViewer.build({ login })); +} + +function stubOrgs(githubClient: FakeGithubClient, logins: string[]): void { + githubClient.onPaginate('GET /user/orgs').resolves(logins.map((login) => githubOrg.build({ login }))); +} + +describe('promptForTarget', () => { + test('shows viewer + orgs + "Other" in the select and returns the picked value', async () => { + const prompter = new FakePrompter().scriptSelect('acme'); + const githubClient = new FakeGithubClient(); + stubViewer(githubClient, 'ben'); + stubOrgs(githubClient, ['acme', 'widgets-co']); + + const result = await promptForTarget({ prompter, githubClient }); + + expect(result.isOk()).toBe(true); + expect(result.unwrapOr('')).toBe('acme'); + const choices = prompter.selects[0]?.choices ?? []; + expect(choices.map((c) => c.value)).toEqual(['ben', 'acme', 'widgets-co', '__other__']); + expect(choices[0]?.hint).toContain('personal'); + expect(choices[1]?.hint).toContain('organization'); + expect(choices.at(-1)?.label).toContain('Other'); + }); + + test('"Other" routes to a free-text prompt with login validation', async () => { + const prompter = new FakePrompter().scriptSelect('__other__').scriptText(' vercel '); + const githubClient = new FakeGithubClient(); + stubViewer(githubClient, 'ben'); + stubOrgs(githubClient, ['acme']); + + const result = await promptForTarget({ prompter, githubClient }); + + expect(result.isOk()).toBe(true); + expect(result.unwrapOr('')).toBe('vercel'); + expect(prompter.texts[0]?.message).toContain('GitHub org or user'); + }); + + test('deduplicates if the viewer login appears in their orgs list', async () => { + const prompter = new FakePrompter().scriptSelect('ben'); + const githubClient = new FakeGithubClient(); + stubViewer(githubClient, 'ben'); + stubOrgs(githubClient, ['ben', 'acme']); + + await promptForTarget({ prompter, githubClient }); + + const values = (prompter.selects[0]?.choices ?? []).map((c) => c.value); + expect(values.filter((v) => v === 'ben')).toHaveLength(1); + }); + + test('falls back to free-text input when GitHub returns no options', async () => { + const prompter = new FakePrompter().scriptText('vercel'); + const githubClient = new FakeGithubClient(); + githubClient.onRequest('GET /user').fails({ kind: 'http', status: 401, message: 'bad token' }); + githubClient.onPaginate('GET /user/orgs').fails({ kind: 'forbidden', message: 'no read:org' }); + + const result = await promptForTarget({ prompter, githubClient }); + + expect(result.isOk()).toBe(true); + expect(result.unwrapOr('')).toBe('vercel'); + expect(prompter.selects).toHaveLength(0); + expect(prompter.texts).toHaveLength(1); + }); + + test('cancellation in the select propagates', async () => { + const prompter = new FakePrompter().scriptSelect({ kind: 'cancelled' }); + const githubClient = new FakeGithubClient(); + stubViewer(githubClient, 'ben'); + stubOrgs(githubClient, ['acme']); + + const result = await promptForTarget({ prompter, githubClient }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ kind: 'cancelled' }); + }); + + test('text-fallback validation: rejects empty + invalid logins, accepts valid ones', async () => { + const prompter = new FakePrompter().scriptText('vercel'); + const githubClient = new FakeGithubClient(); + githubClient.onRequest('GET /user').fails({ kind: 'http', status: 401, message: 'bad token' }); + githubClient.onPaginate('GET /user/orgs').fails({ kind: 'forbidden', message: 'no read:org' }); + + await promptForTarget({ prompter, githubClient }); + const validate = prompter.texts[0]?.validate; + expect(validate?.('')).toContain('enter'); + expect(validate?.('-leading-hyphen')).toBeDefined(); + expect(validate?.('has spaces')).toBeDefined(); + expect(validate?.('vercel')).toBeUndefined(); + expect(validate?.('acme-corp')).toBeUndefined(); + }); +}); diff --git a/src/interactive/targetPrompt.ts b/src/interactive/targetPrompt.ts new file mode 100644 index 0000000..70aa9b7 --- /dev/null +++ b/src/interactive/targetPrompt.ts @@ -0,0 +1,98 @@ +import { ResultAsync } from 'neverthrow'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { PromptError, Prompter } from '../prompt/Prompter.ts'; + +// GitHub login rules: alphanumeric or single hyphens, no leading/trailing hyphen, max 39 chars. +// Same shape applies to both users and orgs. +const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/; + +const OTHER_VALUE = '__other__'; + +export interface TargetPromptDeps { + readonly prompter: Prompter; + readonly githubClient: GithubClient; +} + +interface TargetOption { + readonly login: string; + readonly type: 'user' | 'org'; +} + +export function promptForTarget(deps: TargetPromptDeps): ResultAsync { + const spinner = deps.prompter.spinner(); + spinner.start('Looking up orgs your token can see...'); + return ResultAsync.fromSafePromise(loadOptions(deps.githubClient)).andThen((options) => { + if (options.length === 0) { + spinner.stop("Couldn't list your orgs — type one instead."); + return promptForTargetText(deps.prompter); + } + spinner.stop(`Found ${options.length} option${options.length === 1 ? '' : 's'}.`); + return pickFromOptions(deps.prompter, options); + }); +} + +function pickFromOptions(prompter: Prompter, options: readonly TargetOption[]): ResultAsync { + const choices = [ + ...options.map((o) => ({ + value: o.login, + label: o.login, + hint: o.type === 'user' ? 'your personal account' : 'organization', + })), + { value: OTHER_VALUE, label: 'Other (type a name)', hint: 'analyze any GitHub org or user' }, + ]; + + return prompter + .select({ + message: 'Which GitHub org or user should we analyze?', + choices, + initialValue: options[0]?.login, + }) + .andThen((value) => (value === OTHER_VALUE ? promptForTargetText(prompter) : okString(value))); +} + +function promptForTargetText(prompter: Prompter): ResultAsync { + return prompter + .text({ + message: 'Which GitHub org or user should we analyze?', + placeholder: 'e.g. vercel', + validate: (value) => { + const trimmed = value.trim(); + if (trimmed.length === 0) return 'Please enter a GitHub org or user.'; + if (!GITHUB_LOGIN_PATTERN.test(trimmed)) { + return "That doesn't look like a GitHub login (letters, numbers, single hyphens; up to 39 chars)."; + } + return undefined; + }, + }) + .map((value) => value.trim()); +} + +function okString(value: string): ResultAsync { + // Tiny helper so the andThen branches above type as the same ResultAsync. + return ResultAsync.fromSafePromise(Promise.resolve(value)); +} + +async function loadOptions(client: GithubClient): Promise { + // Best-effort. Either call can fail (e.g. token lacks read:org); we fall back + // to free-text input in that case, so individual errors collapse to "no + // options" rather than killing the prompt. + const [userResult, orgsResult] = await Promise.all([ + client.request<{ login: string }>('GET /user'), + client.paginate<{ login: string }>('GET /user/orgs', { per_page: 100 }), + ]); + + const seen = new Set(); + const options: TargetOption[] = []; + if (userResult.isOk()) { + options.push({ login: userResult.value.login, type: 'user' }); + seen.add(userResult.value.login); + } + if (orgsResult.isOk()) { + for (const org of orgsResult.value) { + if (seen.has(org.login)) continue; + options.push({ login: org.login, type: 'org' }); + seen.add(org.login); + } + } + return options; +} diff --git a/src/interactive/testFactories.ts b/src/interactive/testFactories.ts new file mode 100644 index 0000000..c6cc1d7 --- /dev/null +++ b/src/interactive/testFactories.ts @@ -0,0 +1,36 @@ +import { Factory } from 'fishery'; +import type { AuthError } from '../github/auth.ts'; +import type { FakeContextHandle } from '../testHelpers/createFakeContext.ts'; +import { fakeContextHandle } from '../testHelpers/testFactories.ts'; +import type { SharePromptInputs } from './sharePrompt.ts'; + +export const sharePromptInputs = Factory.define(() => { + const handle = fakeContextHandle.build(); + return { + context: handle.ctx, + target: 'acme', + htmlPath: '/tmp/report.html', + zipPath: '/tmp/report.zip', + htmlContent: '', + zipBytes: new Uint8Array([0x50, 0x4b]), + identifier: 'anon-uuid', + }; +}); + +export const sharePromptInputsFor = ( + handle: FakeContextHandle, + overrides: Partial = {}, +): SharePromptInputs => sharePromptInputs.build({ context: handle.ctx, ...overrides }); + +export const githubViewer = Factory.define<{ login: string }>(() => ({ + login: 'ben', +})); + +export const githubOrg = Factory.define<{ login: string }>(() => ({ + login: 'acme', +})); + +export const noTokenAuthError = Factory.define(() => ({ + kind: 'no-token', + message: 'no token', +})); diff --git a/src/interactive/tokenWalkthrough.test.ts b/src/interactive/tokenWalkthrough.test.ts new file mode 100644 index 0000000..88f47d0 --- /dev/null +++ b/src/interactive/tokenWalkthrough.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { errAsync, okAsync } from 'neverthrow'; +import type { AuthError } from '../github/auth.ts'; +import { FakePrompter } from '../testHelpers/index.ts'; +import { noTokenAuthError } from './testFactories.ts'; +import { interactiveResolveToken } from './tokenWalkthrough.ts'; + +function noToken(message = 'no token'): AuthError { + return noTokenAuthError.build({ message }); +} + +describe('interactiveResolveToken', () => { + test('returns the token immediately when resolve succeeds on first try', async () => { + const prompter = new FakePrompter(); + const result = await interactiveResolveToken({ + prompter, + hasGhCli: () => true, + resolve: () => okAsync('ghp_token'), + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrapOr('')).toBe('ghp_token'); + expect(prompter.notes).toHaveLength(0); + expect(prompter.confirms).toHaveLength(0); + }); + + test('shows gh-cli instructions when gh is on PATH, retries after user presses Enter', async () => { + const prompter = new FakePrompter().scriptConfirm(true); + let calls = 0; + const result = await interactiveResolveToken({ + prompter, + hasGhCli: () => true, + resolve: () => { + calls += 1; + return calls === 1 ? errAsync(noToken()) : okAsync('ghp_token'); + }, + }); + + expect(result.isOk()).toBe(true); + expect(calls).toBe(2); + const note = prompter.notes[0]; + expect(note?.title).toBe('GitHub token required'); + expect(note?.message).toContain('gh auth login --scopes'); + expect(note?.message).toContain('repo,read:org,security_events'); + expect(note?.message).not.toContain('github.com/settings/tokens'); + }); + + test('shows PAT instructions matching the GitHub UI when gh is not installed', async () => { + const prompter = new FakePrompter().scriptConfirm(true); + let calls = 0; + const result = await interactiveResolveToken({ + prompter, + hasGhCli: () => false, + resolve: () => { + calls += 1; + return calls === 1 ? errAsync(noToken()) : okAsync('ghp_token'); + }, + }); + + expect(result.isOk()).toBe(true); + const note = prompter.notes[0]; + expect(note?.message).toContain('https://github.com/settings/tokens/new'); + expect(note?.message).toContain('[x] repo'); + expect(note?.message).toContain('[x] read:org'); + expect(note?.message).toContain('Generate token'); + expect(note?.message).toContain('export GITHUB_TOKEN=ghp_'); + }); + + test('user declines the retry prompt: returns cancelled', async () => { + const prompter = new FakePrompter().scriptConfirm(false); + const result = await interactiveResolveToken({ + prompter, + hasGhCli: () => true, + resolve: () => errAsync(noToken()), + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ kind: 'cancelled' }); + }); + + test('gives up after 3 unsuccessful retries', async () => { + const prompter = new FakePrompter().scriptConfirm(true).scriptConfirm(true).scriptConfirm(true); + let calls = 0; + const result = await interactiveResolveToken({ + prompter, + hasGhCli: () => true, + resolve: () => { + calls += 1; + return errAsync(noToken(`fail ${calls}`)); + }, + }); + + expect(result.isErr()).toBe(true); + expect(calls).toBe(4); // initial + 3 retries + const err = result._unsafeUnwrapErr(); + expect(err.kind).toBe('gave-up'); + }); +}); diff --git a/src/interactive/tokenWalkthrough.ts b/src/interactive/tokenWalkthrough.ts new file mode 100644 index 0000000..44d4b35 --- /dev/null +++ b/src/interactive/tokenWalkthrough.ts @@ -0,0 +1,116 @@ +import { ResultAsync, errAsync } from 'neverthrow'; +import { type AuthError, formatAuthError, resolveToken } from '../github/auth.ts'; +import { type PromptError, type Prompter, formatPromptError } from '../prompt/Prompter.ts'; + +const MAX_ATTEMPTS = 3; + +export interface InteractiveTokenDeps { + readonly prompter: Prompter; + /** Returns the path to `gh` if installed, null otherwise. Defaults to `Bun.which('gh')`. */ + readonly hasGhCli?: () => boolean; + /** Token resolver; defaults to the real one. Override in tests. */ + readonly resolve?: () => ResultAsync; +} + +export type InteractiveTokenError = + | { kind: 'gave-up'; lastError: AuthError } + | { kind: 'cancelled' } + | { kind: 'prompt-failed'; message: string }; + +export function interactiveResolveToken(deps: InteractiveTokenDeps): ResultAsync { + const resolve = deps.resolve ?? resolveToken; + const hasGh = deps.hasGhCli ?? defaultHasGhCli; + return attempt(deps.prompter, resolve, hasGh, 0); +} + +function attempt( + prompter: Prompter, + resolve: () => ResultAsync, + hasGh: () => boolean, + attemptIndex: number, +): ResultAsync { + return resolve() + .map((token) => { + if (attemptIndex > 0) prompter.info('GitHub token detected. Continuing...'); + return token; + }) + .orElse((authErr) => { + if (attemptIndex >= MAX_ATTEMPTS) { + prompter.error(`Still no token after ${MAX_ATTEMPTS} attempts. Last error: ${formatAuthError(authErr)}`); + return errAsync({ kind: 'gave-up', lastError: authErr }); + } + const instructions = hasGh() ? ghInstructions() : patInstructions(); + prompter.note(instructions, attemptIndex === 0 ? 'GitHub token required' : 'Try again'); + return prompter + .confirm({ + message: "Press Enter once you're signed in (or N to abort).", + defaultValue: true, + }) + .mapErr(toInteractiveTokenError) + .andThen((ready) => + ready + ? attempt(prompter, resolve, hasGh, attemptIndex + 1) + : errAsync({ kind: 'cancelled' }), + ); + }); +} + +function defaultHasGhCli(): boolean { + return Bun.which('gh') !== null; +} + +function ghInstructions(): string { + return [ + "We need a GitHub token to read your org's Dependabot data.", + '', + 'I see the gh CLI installed. In another terminal, run:', + '', + ' gh auth login --scopes "repo,read:org,security_events"', + '', + 'Pick GitHub.com → HTTPS → "Login with a web browser" and follow the prompts.', + ].join('\n'); +} + +function patInstructions(): string { + return [ + "We need a GitHub token to read your org's Dependabot data.", + '', + '1. Open https://github.com/settings/tokens/new', + " (You'll be asked to confirm your password.)", + '', + '2. In "Note", enter something memorable, e.g. patchwave-analysis', + '', + '3. In "Expiration", pick whatever you\'re comfortable with', + ' (30 days is the default and is fine — you can revoke it any time).', + '', + '4. Under "Select scopes", tick these top-level boxes:', + '', + ' [x] repo ← grants full repo access; this also auto-ticks', + ' security_events for CVE data', + ' [x] read:org ← it sits under admin:org; tick read:org only', + '', + ' You can leave every other box unchecked.', + '', + '5. Scroll to the bottom and click the green "Generate token" button.', + '', + '6. GitHub will show the token exactly once (it starts with "ghp_").', + ' Copy it, then in this terminal run:', + '', + ' export GITHUB_TOKEN=ghp_...', + ].join('\n'); +} + +function toInteractiveTokenError(err: PromptError): InteractiveTokenError { + return err.kind === 'cancelled' ? { kind: 'cancelled' } : { kind: 'prompt-failed', message: formatPromptError(err) }; +} + +export function formatInteractiveTokenError(err: InteractiveTokenError): string { + switch (err.kind) { + case 'cancelled': + return 'token setup cancelled'; + case 'gave-up': + return `gave up after ${MAX_ATTEMPTS} attempts: ${formatAuthError(err.lastError)}`; + case 'prompt-failed': + return err.message; + } +} diff --git a/src/interactive/ttyGate.test.ts b/src/interactive/ttyGate.test.ts new file mode 100644 index 0000000..7b3a99f --- /dev/null +++ b/src/interactive/ttyGate.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'bun:test'; +import { FakeIo } from '../testHelpers/index.ts'; +import { NON_TTY_EXIT_CODE, NON_TTY_MESSAGE, enforceTty } from './ttyGate.ts'; + +test('passes through when attached to an interactive terminal', () => { + const io = new FakeIo({ isTty: true }); + expect(enforceTty(io)).toEqual({ ok: true }); + expect(io.stderr.text()).toBe(''); +}); + +test('writes the no-terminal message and returns exit code 2 in non-TTY mode', () => { + const io = new FakeIo({ isTty: false }); + const result = enforceTty(io); + expect(result).toEqual({ ok: false, code: NON_TTY_EXIT_CODE }); + expect(io.stderr.text()).toContain('interactive CLI and requires a terminal'); + expect(io.stderr.text()).toBe(NON_TTY_MESSAGE); +}); diff --git a/src/interactive/ttyGate.ts b/src/interactive/ttyGate.ts new file mode 100644 index 0000000..e3b78e8 --- /dev/null +++ b/src/interactive/ttyGate.ts @@ -0,0 +1,20 @@ +import type { Io } from '../BaseIo.ts'; + +export const NON_TTY_EXIT_CODE = 2; +export const NON_TTY_MESSAGE = + 'patchwave-analysis is an interactive CLI and requires a terminal.\n' + + 'Re-run it directly (no pipes/redirects) in an interactive shell.\n'; + +export type TtyGateResult = { ok: true } | { ok: false; code: number }; + +/** + * Refuses to run when the process isn't attached to an interactive terminal. The CLI is interactive end-to-end — + * there are no `--share=...` / `--no-interactive` escape hatches — so a piped + * or CI invocation has nowhere to render the share prompt and we'd rather + * fail loudly than silently drop questions. + */ +export function enforceTty(io: Io): TtyGateResult { + if (io.isTty()) return { ok: true }; + io.writeStderr(NON_TTY_MESSAGE); + return { ok: false, code: NON_TTY_EXIT_CODE }; +} diff --git a/src/prompt/Prompter.ts b/src/prompt/Prompter.ts new file mode 100644 index 0000000..e5d721c --- /dev/null +++ b/src/prompt/Prompter.ts @@ -0,0 +1,119 @@ +import * as clack from '@clack/prompts'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import { toError } from '../errors.ts'; + +export type PromptError = { kind: 'cancelled' } | { kind: 'internal'; message: string }; + +export interface SelectChoice { + readonly value: T; + readonly label: string; + readonly hint?: string; +} + +export interface ConfirmOptions { + readonly message: string; + readonly defaultValue?: boolean; +} + +export interface SelectOptions { + readonly message: string; + readonly choices: readonly SelectChoice[]; + readonly initialValue?: T; +} + +export interface TextOptions { + readonly message: string; + readonly placeholder?: string; + readonly defaultValue?: string; + /** Return a string to reject the input (the string is shown as the error). */ + readonly validate?: (value: string) => string | undefined; +} + +export interface PromptSpinner { + start(msg?: string): void; + stop(msg?: string): void; +} + +export interface Prompter { + intro(msg: string): void; + outro(msg: string): void; + note(msg: string, title?: string): void; + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; + confirm(opts: ConfirmOptions): ResultAsync; + select(opts: SelectOptions): ResultAsync; + text(opts: TextOptions): ResultAsync; + spinner(): PromptSpinner; +} + +function wrap(p: Promise): ResultAsync { + return ResultAsync.fromPromise(p, (e): PromptError => ({ kind: 'internal', message: toError(e).message })).andThen( + (value) => + clack.isCancel(value) ? errAsync({ kind: 'cancelled' }) : okAsync(value), + ); +} + +export class PrompterImpl implements Prompter { + intro(msg: string): void { + clack.intro(msg); + } + + outro(msg: string): void { + clack.outro(msg); + } + + note(msg: string, title?: string): void { + clack.note(msg, title); + } + + info(msg: string): void { + clack.log.info(msg); + } + + warn(msg: string): void { + clack.log.warn(msg); + } + + error(msg: string): void { + clack.log.error(msg); + } + + confirm(opts: ConfirmOptions): ResultAsync { + return wrap(clack.confirm({ message: opts.message, initialValue: opts.defaultValue })); + } + + select(opts: SelectOptions): ResultAsync { + const options = opts.choices.map((c) => { + const option: { value: T; label: string; hint?: string } = { value: c.value, label: c.label }; + if (c.hint !== undefined) option.hint = c.hint; + return option; + }) as Parameters>[0]['options']; + return wrap(clack.select({ message: opts.message, initialValue: opts.initialValue, options })); + } + + text(opts: TextOptions): ResultAsync { + return wrap( + clack.text({ + message: opts.message, + placeholder: opts.placeholder, + defaultValue: opts.defaultValue, + validate: opts.validate ? (value) => opts.validate?.(value ?? '') : undefined, + }), + ); + } + + spinner(): PromptSpinner { + const s = clack.spinner(); + return { start: (m) => s.start(m), stop: (m) => s.stop(m) }; + } +} + +export function formatPromptError(err: PromptError): string { + switch (err.kind) { + case 'cancelled': + return 'cancelled by user'; + case 'internal': + return `prompt failed: ${err.message}`; + } +} diff --git a/src/testHelpers/FakeBrowserOpener.ts b/src/testHelpers/FakeBrowserOpener.ts new file mode 100644 index 0000000..9385b30 --- /dev/null +++ b/src/testHelpers/FakeBrowserOpener.ts @@ -0,0 +1,18 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { BrowserOpenError, BrowserOpener } from '../BrowserOpener.ts'; + +export class FakeBrowserOpener implements BrowserOpener { + readonly opened: string[] = []; + #nextResult: { ok: true } | { ok: false; error: BrowserOpenError } = { ok: true }; + + fails(error: BrowserOpenError): this { + this.#nextResult = { ok: false, error }; + return this; + } + + open(target: string): ResultAsync { + this.opened.push(target); + const next = this.#nextResult; + return next.ok ? okAsync(undefined) : errAsync(next.error); + } +} diff --git a/src/testHelpers/FakeFileSystem.ts b/src/testHelpers/FakeFileSystem.ts index 8f7cf63..f8e1946 100644 --- a/src/testHelpers/FakeFileSystem.ts +++ b/src/testHelpers/FakeFileSystem.ts @@ -8,12 +8,19 @@ export interface FakeWrite { export class FakeFileSystem implements FileSystem { readonly writes: FakeWrite[] = []; + readonly tempDirs: string[] = []; private failure: FsError | null = null; + private tempDirFailure: FsError | null = null; + private tempDirCounter = 0; failNextWriteWith(err: FsError): void { this.failure = err; } + failNextTempDirWith(err: FsError): void { + this.tempDirFailure = err; + } + writeTextFile(path: string, contents: string): ResultAsync { return this.recordWrite(path, contents); } @@ -22,6 +29,17 @@ export class FakeFileSystem implements FileSystem { return this.recordWrite(path, contents); } + makeTempDir(prefix: string): ResultAsync { + if (this.tempDirFailure) { + const err = this.tempDirFailure; + this.tempDirFailure = null; + return errAsync(err); + } + const path = `/fake-tmp/${prefix}${this.tempDirCounter++}`; + this.tempDirs.push(path); + return okAsync(path); + } + read(path: string): string | undefined { const contents = this.writes.find((w) => w.path === path)?.contents; return typeof contents === 'string' ? contents : undefined; diff --git a/src/testHelpers/FakeIo.ts b/src/testHelpers/FakeIo.ts index 620fc5c..53d082d 100644 --- a/src/testHelpers/FakeIo.ts +++ b/src/testHelpers/FakeIo.ts @@ -1,11 +1,24 @@ import { BaseIo } from '../BaseIo.ts'; import { MemoryStream } from './MemoryStream.ts'; +export interface FakeIoOptions { + /** + * What `isTty()` should return. Defaults to `true` so newly written + * tests exercise the interactive path; flip to `false` to assert the + * "requires a terminal" gate. + */ + readonly isTty?: boolean; +} + export class FakeIo extends BaseIo { declare readonly stdout: MemoryStream; declare readonly stderr: MemoryStream; - constructor() { - super({ stdout: new MemoryStream(), stderr: new MemoryStream() }); + constructor(options: FakeIoOptions = {}) { + super({ + stdout: new MemoryStream(), + stderr: new MemoryStream(), + isTty: options.isTty ?? true, + }); } } diff --git a/src/testHelpers/FakePrompter.ts b/src/testHelpers/FakePrompter.ts new file mode 100644 index 0000000..9819fd6 --- /dev/null +++ b/src/testHelpers/FakePrompter.ts @@ -0,0 +1,127 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { + ConfirmOptions, + PromptError, + PromptSpinner, + Prompter, + SelectOptions, + TextOptions, +} from '../prompt/Prompter.ts'; + +type AnswerKind = 'confirm' | 'select' | 'text'; + +interface ScriptedAnswer { + readonly kind: K; + readonly value: V | PromptError; +} + +type ConfirmAnswer = ScriptedAnswer<'confirm', boolean>; +type SelectAnswer = ScriptedAnswer<'select', string>; +type TextAnswer = ScriptedAnswer<'text', string>; +type Answer = ConfirmAnswer | SelectAnswer | TextAnswer; + +export interface SpinnerEvent { + readonly type: 'start' | 'stop'; + readonly message?: string; +} + +export class FakePrompter implements Prompter { + readonly intros: string[] = []; + readonly outros: string[] = []; + readonly notes: { message: string; title?: string }[] = []; + readonly infos: string[] = []; + readonly warns: string[] = []; + readonly errors: string[] = []; + readonly confirms: ConfirmOptions[] = []; + readonly selects: SelectOptions[] = []; + readonly texts: TextOptions[] = []; + readonly spinnerEvents: SpinnerEvent[] = []; + + readonly #answers: Answer[] = []; + + scriptConfirm(value: boolean | PromptError): this { + this.#answers.push({ kind: 'confirm', value }); + return this; + } + + scriptSelect(value: string | PromptError): this { + this.#answers.push({ kind: 'select', value }); + return this; + } + + scriptText(value: string | PromptError): this { + this.#answers.push({ kind: 'text', value }); + return this; + } + + intro(msg: string): void { + this.intros.push(msg); + } + + outro(msg: string): void { + this.outros.push(msg); + } + + note(msg: string, title?: string): void { + this.notes.push({ message: msg, title }); + } + + info(msg: string): void { + this.infos.push(msg); + } + + warn(msg: string): void { + this.warns.push(msg); + } + + error(msg: string): void { + this.errors.push(msg); + } + + confirm(opts: ConfirmOptions): ResultAsync { + this.confirms.push(opts); + const next = this.#shift('confirm'); + if (isPromptError(next.value)) return errAsync(next.value); + return okAsync(next.value); + } + + select(opts: SelectOptions): ResultAsync { + this.selects.push(opts); + const next = this.#shift('select'); + if (isPromptError(next.value)) return errAsync(next.value); + return okAsync(next.value as T); + } + + text(opts: TextOptions): ResultAsync { + this.texts.push(opts); + const next = this.#shift('text'); + if (isPromptError(next.value)) return errAsync(next.value); + return okAsync(next.value); + } + + spinner(): PromptSpinner { + return { + start: (message?: string) => { + this.spinnerEvents.push({ type: 'start', message }); + }, + stop: (message?: string) => { + this.spinnerEvents.push({ type: 'stop', message }); + }, + }; + } + + #shift(expected: K): Extract { + const next = this.#answers.shift(); + if (next === undefined) { + throw new Error(`FakePrompter: ran out of scripted answers (expected ${expected})`); + } + if (next.kind !== expected) { + throw new Error(`FakePrompter: expected ${expected} answer, got scripted ${next.kind}`); + } + return next as Extract; + } +} + +function isPromptError(value: unknown): value is PromptError { + return typeof value === 'object' && value !== null && 'kind' in value; +} diff --git a/src/testHelpers/FakeUploader.ts b/src/testHelpers/FakeUploader.ts new file mode 100644 index 0000000..1870734 --- /dev/null +++ b/src/testHelpers/FakeUploader.ts @@ -0,0 +1,26 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { UploadError, UploadInput, UploadResult, Uploader } from '../upload/Uploader.ts'; + +export class FakeUploader implements Uploader { + readonly calls: UploadInput[] = []; + #nextResult: { ok: true; value: UploadResult } | { ok: false; error: UploadError } = { + ok: true, + value: { uploadId: 'fake-upload-id' }, + }; + + resolves(value: UploadResult): this { + this.#nextResult = { ok: true, value }; + return this; + } + + fails(error: UploadError): this { + this.#nextResult = { ok: false, error }; + return this; + } + + upload(input: UploadInput): ResultAsync { + this.calls.push(input); + const next = this.#nextResult; + return next.ok ? okAsync(next.value) : errAsync(next.error); + } +} diff --git a/src/testHelpers/createFakeContext.ts b/src/testHelpers/createFakeContext.ts index cb68b47..fb5f847 100644 --- a/src/testHelpers/createFakeContext.ts +++ b/src/testHelpers/createFakeContext.ts @@ -3,10 +3,13 @@ import type { Context } from '../context.ts'; import type { Environment } from '../environment.ts'; import type { Logger } from '../logger.ts'; import { FakeAnalytics } from './FakeAnalytics.ts'; +import { FakeBrowserOpener } from './FakeBrowserOpener.ts'; import { FakeClock } from './FakeClock.ts'; import { FakeFileSystem } from './FakeFileSystem.ts'; import { FakeGithubClient } from './FakeGithubClient.ts'; -import { FakeIo } from './FakeIo.ts'; +import { FakeIo, type FakeIoOptions } from './FakeIo.ts'; +import { FakePrompter } from './FakePrompter.ts'; +import { FakeUploader } from './FakeUploader.ts'; export interface FakeContextHandle { readonly ctx: Context; @@ -16,6 +19,14 @@ export interface FakeContextHandle { readonly fs: FakeFileSystem; readonly githubClient: FakeGithubClient; readonly analytics: FakeAnalytics; + readonly prompter: FakePrompter; + readonly uploader: FakeUploader; + readonly browserOpener: FakeBrowserOpener; +} + +export interface CreateFakeContextOptions { + readonly overrides?: Partial; + readonly io?: FakeIoOptions; } const defaultEnv: Environment = { @@ -25,18 +36,45 @@ const defaultEnv: Environment = { CI: false, }; -export function createFakeContext(): FakeContextHandle { - const io = new FakeIo(); +export function createFakeContext(options: CreateFakeContextOptions = {}): FakeContextHandle { + const io = new FakeIo(options.io); const clock = new FakeClock(); const fs = new FakeFileSystem(); const githubClient = new FakeGithubClient(); const analytics = new FakeAnalytics(); + const prompter = new FakePrompter(); + const uploader = new FakeUploader(); + const browserOpener = new FakeBrowserOpener(); // Route fake logger output to FakeIo.stderr (raw pino JSON, no pino-pretty) so // tests can substring-match log content via io.stderr.text(). const logger: Logger = pino({ level: 'trace' }, io.stderr); - const ctx: Context = { io, logger, env: defaultEnv, clock, fs, githubClient, analytics }; + const ctx: Context = { + io, + logger, + env: defaultEnv, + clock, + fs, + githubClient, + analytics, + prompter, + uploader, + browserOpener, + appVersion: '0.0.0-test', + ...options.overrides, + }; - return { ctx, io, logger, clock, fs, githubClient, analytics }; + return { + ctx, + io: ctx.io instanceof FakeIo ? ctx.io : io, + logger: ctx.logger, + clock: ctx.clock instanceof FakeClock ? ctx.clock : clock, + fs: ctx.fs instanceof FakeFileSystem ? ctx.fs : fs, + githubClient: ctx.githubClient instanceof FakeGithubClient ? ctx.githubClient : githubClient, + analytics: ctx.analytics instanceof FakeAnalytics ? ctx.analytics : analytics, + prompter: ctx.prompter instanceof FakePrompter ? ctx.prompter : prompter, + uploader: ctx.uploader instanceof FakeUploader ? ctx.uploader : uploader, + browserOpener: ctx.browserOpener instanceof FakeBrowserOpener ? ctx.browserOpener : browserOpener, + }; } diff --git a/src/testHelpers/index.ts b/src/testHelpers/index.ts index 1edf267..d719cee 100644 --- a/src/testHelpers/index.ts +++ b/src/testHelpers/index.ts @@ -1,2 +1,7 @@ export { FakeGithubClient } from './FakeGithubClient.ts'; +export { FakeIo } from './FakeIo.ts'; +export { FakeAnalytics } from './FakeAnalytics.ts'; +export { FakeBrowserOpener } from './FakeBrowserOpener.ts'; +export { FakePrompter } from './FakePrompter.ts'; +export { FakeUploader } from './FakeUploader.ts'; export { createFakeContext } from './createFakeContext.ts'; diff --git a/src/testHelpers/testFactories.ts b/src/testHelpers/testFactories.ts new file mode 100644 index 0000000..5f084d6 --- /dev/null +++ b/src/testHelpers/testFactories.ts @@ -0,0 +1,9 @@ +import { Factory } from 'fishery'; +import { type FakeContextHandle, createFakeContext } from './createFakeContext.ts'; +import { FakeClock } from './FakeClock.ts'; + +export const fakeContextHandle = Factory.define(() => + createFakeContext({ + overrides: { clock: new FakeClock('2026-05-22T12:00:00Z'), appVersion: '0.0.1' }, + }), +); diff --git a/src/upload/Uploader.test.ts b/src/upload/Uploader.test.ts new file mode 100644 index 0000000..606d6ce --- /dev/null +++ b/src/upload/Uploader.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test'; +import { htmlBytes, presignResponseBody, uploadInput, zipBytes } from './testFactories.ts'; +import { type FetchFn, UploaderImpl } from './Uploader.ts'; + +const ENDPOINT = 'https://api.test/v1/uploads/analysis-bundle'; + +interface FetchCall { + readonly url: string; + readonly init: RequestInit | undefined; +} + +function recordFetch(responses: Response[]): { fetch: FetchFn; calls: FetchCall[] } { + const calls: FetchCall[] = []; + const queue = [...responses]; + const fetchFn: FetchFn = (url, init) => { + calls.push({ url, init }); + const next = queue.shift(); + if (!next) throw new Error('no more responses'); + return Promise.resolve(next); + }; + return { fetch: fetchFn, calls }; +} + +function presignResponse() { + return new Response(JSON.stringify(presignResponseBody.build()), { status: 200 }); +} + +describe('UploaderImpl', () => { + test("zip kind: posts kind:'zip' and PUTs with application/zip", async () => { + const { fetch, calls } = recordFetch([presignResponse(), new Response('', { status: 200 })]); + const bytes = zipBytes.build(); + + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build({ bytes })); + + expect(result.isOk()).toBe(true); + expect(result.unwrapOr(null)).toEqual({ uploadId: 'uuid-1' }); + + expect(calls[0]?.url).toBe(ENDPOINT); + expect(calls[0]?.init?.method).toBe('POST'); + const postBody = JSON.parse(calls[0]?.init?.body as string) as Record; + expect(postBody).toMatchObject({ + identifier: 'ben@example.com', + appVersion: '0.0.1', + timestamp: '2026-05-22T12:00:00Z', + kind: 'zip', + sizeBytes: bytes.byteLength, + }); + expect(postBody).not.toHaveProperty('contentType'); + + expect(calls[1]?.url).toBe(presignResponseBody.build().presignedUrl); + expect(calls[1]?.init?.method).toBe('PUT'); + expect(calls[1]?.init?.body).toBe(bytes as BodyInit); + expect((calls[1]?.init?.headers as Record)['content-type']).toBe('application/zip'); + }); + + test("html kind: posts kind:'html' and PUTs raw bytes with text/html", async () => { + const { fetch, calls } = recordFetch([presignResponse(), new Response('', { status: 200 })]); + const bytes = htmlBytes.build(); + + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload( + uploadInput.build({ bytes, kind: 'html' }), + ); + + expect(result.isOk()).toBe(true); + const postBody = JSON.parse(calls[0]?.init?.body as string) as Record; + expect(postBody).toMatchObject({ kind: 'html', sizeBytes: bytes.byteLength }); + expect(calls[1]?.init?.body).toBe(bytes as BodyInit); + expect((calls[1]?.init?.headers as Record)['content-type']).toBe('text/html'); + }); + + test('presign returns non-2xx → presign-bad-status', async () => { + const { fetch } = recordFetch([new Response('rate limited', { status: 429 })]); + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ + kind: 'presign-bad-status', + status: 429, + body: 'rate limited', + }); + }); + + test('presign returns malformed JSON → presign-bad-response', async () => { + const { fetch } = recordFetch([new Response('{not json', { status: 200 })]); + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr().kind).toBe('presign-bad-response'); + }); + + test('presign returns JSON missing required fields → presign-bad-response', async () => { + const { fetch } = recordFetch([new Response(JSON.stringify({ uploadId: 'x' }), { status: 200 })]); + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); + + expect(result.isErr()).toBe(true); + const err = result._unsafeUnwrapErr(); + expect(err.kind).toBe('presign-bad-response'); + if (err.kind === 'presign-bad-response') { + expect(err.message).toContain('presignedUrl'); + } + }); + + test('S3 PUT returns non-2xx → s3-bad-status', async () => { + const { fetch } = recordFetch([presignResponse(), new Response('access denied', { status: 403 })]); + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch }).upload(uploadInput.build()); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ + kind: 's3-bad-status', + status: 403, + body: 'access denied', + }); + }); + + test('network failure during presign → presign-request-failed', async () => { + const fetchFn: FetchFn = () => Promise.reject(new Error('econnreset')); + const result = await new UploaderImpl({ endpoint: ENDPOINT, fetch: fetchFn }).upload(uploadInput.build()); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toEqual({ + kind: 'presign-request-failed', + message: 'econnreset', + }); + }); +}); diff --git a/src/upload/Uploader.ts b/src/upload/Uploader.ts new file mode 100644 index 0000000..d32bf77 --- /dev/null +++ b/src/upload/Uploader.ts @@ -0,0 +1,152 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import { toError } from '../errors.ts'; + +export const DEFAULT_UPLOAD_ENDPOINT = 'https://api.patchwave.ai/v1/uploads/analysis-bundle'; + +export type BundleKind = 'zip' | 'html'; + +// The server keys off `kind` and rejects requests whose PUT content-type doesn't +// match — `content-type` is part of the signed headers — so the canonical +// mapping lives here, not on the client's choice of MIME string. +const BUNDLE_CONTENT_TYPES: Record = { + zip: 'application/zip', + html: 'text/html', +}; + +export type UploadError = + | { kind: 'presign-request-failed'; message: string } + | { kind: 'presign-bad-status'; status: number; body: string } + | { kind: 'presign-bad-response'; message: string } + | { kind: 's3-put-failed'; message: string } + | { kind: 's3-bad-status'; status: number; body: string }; + +export interface UploadInput { + readonly bytes: Uint8Array; + readonly kind: BundleKind; + readonly identifier: string; + readonly appVersion: string; + readonly timestamp: string; +} + +export interface UploadResult { + readonly uploadId: string; +} + +export interface Uploader { + upload(input: UploadInput): ResultAsync; +} + +export type FetchFn = (url: string, init?: RequestInit) => Promise; + +export interface UploaderImplOptions { + readonly endpoint?: string; + readonly fetch?: FetchFn; +} + +interface PresignResponse { + readonly uploadId: string; + readonly presignedUrl: string; + readonly expiresAt: string; +} + +export class UploaderImpl implements Uploader { + readonly #endpoint: string; + readonly #fetch: FetchFn; + + constructor(options: UploaderImplOptions = {}) { + this.#endpoint = options.endpoint ?? DEFAULT_UPLOAD_ENDPOINT; + this.#fetch = options.fetch ?? fetch; + } + + upload(input: UploadInput): ResultAsync { + return this.#requestPresign(input).andThen((presign) => + this.#putToS3(presign.presignedUrl, input.kind, input.bytes).map(() => ({ uploadId: presign.uploadId })), + ); + } + + #requestPresign(input: UploadInput): ResultAsync { + const body = JSON.stringify({ + identifier: input.identifier, + appVersion: input.appVersion, + timestamp: input.timestamp, + kind: input.kind, + sizeBytes: input.bytes.byteLength, + }); + return ResultAsync.fromPromise( + this.#fetch(this.#endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }), + (e): UploadError => ({ kind: 'presign-request-failed', message: toError(e).message }), + ).andThen((res) => { + if (!res.ok) { + return ResultAsync.fromSafePromise(res.text().catch(() => '')).andThen((text) => + errAsync({ kind: 'presign-bad-status', status: res.status, body: text }), + ); + } + return ResultAsync.fromPromise( + res.json(), + (e): UploadError => ({ kind: 'presign-bad-response', message: toError(e).message }), + ).andThen(parsePresign); + }); + } + + #putToS3(url: string, kind: BundleKind, bytes: Uint8Array): ResultAsync { + return ResultAsync.fromPromise( + this.#fetch(url, { + method: 'PUT', + headers: { 'content-type': BUNDLE_CONTENT_TYPES[kind] }, + // The DOM lib's `BodyInit` narrows `BufferSource` to `Uint8Array`, + // but our bytes are `Uint8Array`. fetch accepts them at runtime. + body: bytes as BodyInit, + }), + (e): UploadError => ({ kind: 's3-put-failed', message: toError(e).message }), + ).andThen((res) => { + if (!res.ok) { + return ResultAsync.fromSafePromise(res.text().catch(() => '')).andThen((text) => + errAsync({ kind: 's3-bad-status', status: res.status, body: text }), + ); + } + return okAsync(undefined); + }); + } +} + +function parsePresign(value: unknown): ResultAsync { + if (!isObject(value)) { + return errAsync({ + kind: 'presign-bad-response', + message: 'response was not a JSON object', + }); + } + const uploadId = value['uploadId']; + const presignedUrl = value['presignedUrl']; + const expiresAt = value['expiresAt']; + if (typeof uploadId !== 'string' || typeof presignedUrl !== 'string' || typeof expiresAt !== 'string') { + return errAsync({ + kind: 'presign-bad-response', + message: 'response missing uploadId/presignedUrl/expiresAt', + }); + } + return okAsync({ uploadId, presignedUrl, expiresAt }); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function formatUploadError(err: UploadError): string { + switch (err.kind) { + case 'presign-request-failed': + return `failed to reach upload service: ${err.message}`; + case 'presign-bad-status': + return `upload service returned ${err.status}: ${err.body || '(empty body)'}`; + case 'presign-bad-response': + return `upload service returned an unexpected response: ${err.message}`; + case 's3-put-failed': + return `failed to upload to S3: ${err.message}`; + case 's3-bad-status': + return `S3 returned ${err.status}: ${err.body || '(empty body)'}`; + } +} diff --git a/src/upload/testFactories.ts b/src/upload/testFactories.ts new file mode 100644 index 0000000..31aa995 --- /dev/null +++ b/src/upload/testFactories.ts @@ -0,0 +1,26 @@ +import { Factory } from 'fishery'; +import type { UploadInput } from './Uploader.ts'; + +export const zipBytes = Factory.define(() => new Uint8Array([0x50, 0x4b, 0x03, 0x04])); + +export const htmlBytes = Factory.define(() => new TextEncoder().encode('')); + +export const uploadInput = Factory.define(() => ({ + bytes: zipBytes.build(), + kind: 'zip', + identifier: 'ben@example.com', + appVersion: '0.0.1', + timestamp: '2026-05-22T12:00:00Z', +})); + +export interface PresignResponseBody { + readonly uploadId: string; + readonly presignedUrl: string; + readonly expiresAt: string; +} + +export const presignResponseBody = Factory.define(() => ({ + uploadId: 'uuid-1', + presignedUrl: 'https://s3.test/some-bucket/abc?signed=1', + expiresAt: '2026-05-22T13:00:00Z', +}));