diff --git a/biome.jsonc b/biome.jsonc index 1cb2f9146..b70e09a1a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -33,6 +33,26 @@ } }, "overrides": [ + { + // The React-hook lint rules infer "this is a hook" from the + // `use*` naming convention. We have a couple of test helpers + // (`useTestConfigDir`, `useEnvSandbox`) that share the prefix + // by coincidence — they register `beforeEach`/`afterEach` and + // have nothing to do with React. Without these overrides every + // call site lights up `useHookAtTopLevel` since making the + // tsconfig JSX-aware (for `OpenTuiUI`) flipped the rule on. + // The actual React tree lives in `src/lib/init/ui/opentui-app.tsx` + // and keeps the rule active. + "includes": ["test/**/*.ts", "src/**/*.ts", "!src/**/*.tsx"], + "linter": { + "rules": { + "correctness": { + "useHookAtTopLevel": "off", + "useExhaustiveDependencies": "off" + } + } + } + }, { "includes": ["test/**/*.ts"], "linter": { diff --git a/bun.lock b/bun.lock index ead0d2419..cbcbee40b 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,6 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", - "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", @@ -19,6 +18,7 @@ "@types/node": "^22", "@types/picomatch": "^4.0.3", "@types/qrcode-terminal": "^0.12.2", + "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "binpunch": "^1.0.0", "chalk": "^5.6.2", @@ -28,12 +28,16 @@ "fast-check": "^4.5.3", "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", + "ink": "^7.0.1", + "ink-spinner": "^5.0.0", "marked": "^15", "p-limit": "^7.2.0", "peggy": "^5.1.0", "picomatch": "^4.0.3", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", + "react": "^19.2.5", + "react-devtools-core": "^7.0.1", "semver": "^7.7.3", "string-width": "^8.2.0", "tinyglobby": "^0.2.15", @@ -67,6 +71,8 @@ "@ai-sdk/ui-utils-v5": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], @@ -233,6 +239,8 @@ "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], "@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], @@ -253,6 +261,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -265,6 +275,8 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "binpunch": ["binpunch@1.0.0", "", { "bin": { "binpunch": "dist/cli.js" } }, "sha512-ghxdoerLN3WN64kteDJuL4d9dy7gbvcqoADNRWBk6aQ5FrYH1EmPmREAdcdIdTNAA3uW3V38Env5OqH2lj+i+g=="], @@ -285,10 +297,20 @@ "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + "cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@6.0.0", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="], + "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -303,6 +325,8 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], @@ -311,6 +335,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -331,6 +357,8 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -339,13 +367,15 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.46.0", "", {}, "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -425,15 +455,23 @@ "import-in-the-middle": ["import-in-the-middle@3.0.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@7.0.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w=="], + + "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], "is-network-error": ["is-network-error@1.3.1", "", {}, "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw=="], @@ -477,6 +515,8 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], @@ -505,6 +545,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "p-limit": ["p-limit@7.2.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ=="], @@ -521,6 +563,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], @@ -555,16 +599,26 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + + "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], @@ -581,6 +635,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], @@ -589,12 +645,18 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "source-map-generator": ["source-map-generator@2.0.6", "", {}, "sha512-IlassDs1Ve8nV6uyQZXF9kdkJpVKnMte2JZQXu13M0A5zwc+vu6+LNHfmxsHBMDtoZE21RHiKI0/xvpecZRCNg=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], @@ -605,6 +667,10 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], @@ -619,6 +685,8 @@ "trpc-cli": ["trpc-cli@0.12.2", "", { "dependencies": { "commander": "^14.0.0" }, "peerDependencies": { "@orpc/server": "^1.0.0", "@trpc/server": "^10.45.2 || ^11.0.1", "@valibot/to-json-schema": "^1.1.0", "effect": "^3.14.2 || ^4.0.0", "valibot": "^1.1.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["@orpc/server", "@trpc/server", "@valibot/to-json-schema", "effect", "valibot", "zod"], "bin": { "trpc-cli": "dist/bin.js" } }, "sha512-kGNCiyOimGlfcZFImbWzFF2Nn3TMnenwUdyuckiN5SEaceJbIac7+Iau3WsVHjQpoNgugFruZMDOKf8GNQNtJw=="], + "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -645,10 +713,14 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -659,6 +731,8 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-from-json-schema": ["zod-from-json-schema@0.5.2", "", { "dependencies": { "zod": "^4.0.17" } }, "sha512-/dNaicfdhJTOuUd4RImbLUE2g5yrSzzDjI/S6C2vO2ecAGZzn9UcRVgtyLSnENSmAOBRiSpUdzDS6fDWX3Z35g=="], @@ -681,6 +755,10 @@ "@peggyjs/from-mem/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@sindresorhus/slugify/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "@sindresorhus/transliterate/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -703,6 +781,8 @@ "path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -739,6 +819,8 @@ "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -757,6 +839,8 @@ "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], diff --git a/package.json b/package.json index acbc28168..711b6dd61 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", "@biomejs/biome": "2.3.8", - "@clack/prompts": "^0.11.0", "@mastra/client-js": "^1.4.0", "@sentry/api": "^0.113.0", "@sentry/node-core": "10.50.0", @@ -21,6 +20,7 @@ "@types/node": "^22", "@types/picomatch": "^4.0.3", "@types/qrcode-terminal": "^0.12.2", + "@types/react": "^19.2.14", "@types/semver": "^7.7.1", "binpunch": "^1.0.0", "chalk": "^5.6.2", @@ -30,12 +30,16 @@ "fast-check": "^4.5.3", "http-cache-semantics": "^4.2.0", "ignore": "^7.0.5", + "ink": "^7.0.1", + "ink-spinner": "^5.0.0", "marked": "^15", "p-limit": "^7.2.0", "peggy": "^5.1.0", "picomatch": "^4.0.3", "pretty-ms": "^9.3.0", "qrcode-terminal": "^0.12.0", + "react": "^19.2.5", + "react-devtools-core": "^7.0.1", "semver": "^7.7.3", "string-width": "^8.2.0", "tinyglobby": "^0.2.15", diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md index 27abdc90f..e96a83af2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -20,6 +20,7 @@ Initialize Sentry in your project (experimental) - `-n, --dry-run - Show what would happen without making changes` - `--features ... - Features to enable: errors,tracing,logs,replay,profiling,ai-monitoring,user-feedback` - `-t, --team - Team slug to create the project under` +- `--tui - Use the Ink-based interactive UI (default on the Bun binary). Pass --no-tui to fall back to plain log output.` **Examples:** diff --git a/script/build.ts b/script/build.ts index c320d69dc..73a8e0165 100644 --- a/script/build.ts +++ b/script/build.ts @@ -124,7 +124,27 @@ async function bundleJs(): Promise { platform: "node", target: "esnext", format: "esm", - external: ["bun:*"], + // Externalize the Ink + React stack from the esbuild bundling + // step. `react`'s CJS jsx-runtime, when pulled into esbuild's + // `__commonJS` wrappers and re-bundled by Bun.compile, produces + // malformed output containing a TDZ `init_react` symbol + // embedded in the wrong scope. Keeping React (and its + // consumers) external lets Bun's runtime resolve them fresh at + // first invocation, outside the buggy bundler path. + // + // The npm bundle (`script/bundle.ts`) externalizes the same + // packages for the same reason — bundling Ink's React tree + // through esbuild produces a CJS wrapper that hits a TDZ at + // runtime when React is first touched. + external: [ + "bun:*", + "ink", + "ink-spinner", + "react", + "react/*", + "react-reconciler", + "react-reconciler/*", + ], sourcemap: "linked", // Minify syntax and whitespace but NOT identifiers. Bun.build minify: true, @@ -295,6 +315,25 @@ async function compileTarget(target: BuildTarget): Promise { try { const result = await Bun.build({ entrypoints: [BUNDLE_JS], + // Force React to load its production builds. React's CJS + // entry switches at runtime via + // `if (process.env.NODE_ENV === "production")` + // — leaving NODE_ENV unset would drag in the development + // builds, whose CJS wrappers Bun.compile can't bundle cleanly + // (it injects `__promiseAll` runtime helpers in positions the + // dev-build's IIFE doesn't tolerate, causing a SyntaxError at + // startup). Production builds parse fine. + // + // `react-devtools-core` is gated behind `process.env.DEV === + // "true"` inside Ink's reconciler — never reached in our + // production binary. We still install it as a devDep so + // Bun.compile can resolve the static `import devtools from + // "react-devtools-core"` reference; without it the build + // fails with "Could not resolve". The inlined module gets + // dead-code-eliminated by the DEV gate at runtime. + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, compile: { target: getBunTarget(target) as | "bun-darwin-arm64" @@ -480,8 +519,12 @@ async function build(): Promise { // Step 3: Upload the composed sourcemap to Sentry (after compilation) await uploadSourcemapToSentry(); - // Clean up intermediate bundle (only the binaries are artifacts) - await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE}`; + // Clean up intermediate bundle (only the binaries are artifacts). + // The `ink-app.tsx` copy comes from the text-import-plugin's + // `with { type: "file" }` handling — it gets embedded into the + // compiled binary, so the sidecar copy is no longer needed once + // every target has compiled. + await $`rm -f ${BUNDLE_JS} ${SOURCEMAP_FILE} dist-bin/ink-app.tsx`; // Summary console.log(`\n${"=".repeat(40)}`); diff --git a/script/bundle.ts b/script/bundle.ts index 0949163bb..0e60bc58a 100644 --- a/script/bundle.ts +++ b/script/bundle.ts @@ -215,8 +215,30 @@ const result = await build({ // Replace import.meta.url with the injected shim variable for CJS "import.meta.url": "import_meta_url", }, - // Only externalize Node.js built-ins - bundle all npm packages - external: ["node:*"], + // Externalize Node.js built-ins, plus Ink + React + companions. + // Ink uses top-level await (in `node_modules/ink/build/reconciler.js` + // and `yoga-layout/dist/src/index.js`) which esbuild can't emit in + // a CJS bundle, so the packages must stay external for the + // npm/Node distribution. The factory in `factory.ts` lazy-imports + // the Ink path via `with { type: "file" }` and falls back to + // `LoggingUI` on import failure, so a Node user without Ink + // installed simply gets the non-TUI flow without a crash. + // + // The Bun compile (`script/build.ts`) embeds `ink-app.tsx` as a + // file resource — at runtime Bun's loader resolves Ink + React + // fresh, sidestepping the same CJS-wrapping bug that'd hit if + // these were bundled into the binary's pre-compiled JS. + external: [ + "node:*", + "ink", + "ink-spinner", + "react", + "react/*", + "react-reconciler", + "react-reconciler/*", + "react-devtools-core", + "yoga-layout", + ], metafile: true, plugins, }); @@ -278,6 +300,20 @@ await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS); console.log(" -> dist/bin.cjs (CLI wrapper)"); console.log(" -> dist/index.d.cts (type declarations)"); +// Clean up the `ink-app.tsx` sidecar that the text-import-plugin +// drops into `dist/` when it sees the `with { type: "file" }` import +// in `src/lib/init/ui/ink-ui.ts`. The npm distribution doesn't run +// the InkUI factory at all (it's gated to the Bun binary because +// Ink uses top-level await that we can't bundle into CJS), so the +// sidecar is unused — and it's not in `package.json#files` either, +// so it wouldn't ship even without this cleanup. Removing it just +// keeps the local `dist/` directory tidy. +try { + await unlink("./dist/ink-app.tsx"); +} catch { + // Sidecar may not exist (e.g. plugin path not exercised) — fine. +} + // Calculate bundle size (only the main bundle, not source maps) const bundleOutput = result.metafile?.outputs["dist/index.cjs"]; const bundleSize = bundleOutput?.bytes ?? 0; diff --git a/script/text-import-plugin.ts b/script/text-import-plugin.ts index 9533075dd..5d36df85b 100644 --- a/script/text-import-plugin.ts +++ b/script/text-import-plugin.ts @@ -1,17 +1,28 @@ /** - * esbuild plugin that polyfills Bun's `with { type: "text" }` import - * attribute (esbuild only supports `json`). Intercepts matching - * imports, reads the file, and default-exports its contents as a - * string. Runtime behavior matches Bun's native handling. + * esbuild plugin that polyfills Bun's `with { type: "text" }` and + * `with { type: "file" }` import attributes (esbuild only supports + * `json`). + * + * - `text` — intercepts the import, reads the file, and default- + * exports its contents as a string. Runtime behavior matches Bun's + * native handling. + * - `file` — copies the source file into the esbuild output + * directory, then marks the import external so the original + * `import path from "./foo" with { type: "file" }` clause + * survives in the bundled JS. Bun.compile downstream understands + * the attribute natively, embeds the file as a binary asset, and + * resolves the import to a virtual-filesystem path string at + * runtime. * * Used by `script/build.ts` (single-file executable) and * `script/bundle.ts` (CJS library bundle) so the grep-worker source * in `src/lib/scan/worker-pool.ts` loads correctly in both dev and - * compiled builds. + * compiled builds (`text` branch). The `file` branch is kept for + * future use; today no source file goes through it. */ -import { readFileSync } from "node:fs"; -import { resolve as resolvePath } from "node:path"; +import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; +import { basename, dirname, resolve as resolvePath } from "node:path"; import type { Plugin } from "esbuild"; const TEXT_IMPORT_NS = "text-import"; @@ -21,13 +32,48 @@ export const textImportPlugin: Plugin = { name: "text-import", setup(build) { build.onResolve({ filter: ANY_FILTER }, (args) => { - if (args.with?.type !== "text") { - return null; + if (args.with?.type === "text") { + return { + path: resolvePath(args.resolveDir, args.path), + namespace: TEXT_IMPORT_NS, + }; } - return { - path: resolvePath(args.resolveDir, args.path), - namespace: TEXT_IMPORT_NS, - }; + if (args.with?.type === "file") { + // Copy the source into the bundle's output directory and + // rewrite the import path so it sits next to the bundle. + // esbuild keeps the import external (preserving the + // `with { type: "file" }` clause) so Bun.compile can pick + // it up from the new location. The copy is needed because + // Bun.compile resolves imports relative to the bundle file's + // directory at compile time, not the original source. + // + // `mkdirSync` guards against the bundle's `outdir` not yet + // existing when the plugin fires — esbuild creates the + // outdir lazily on first write. + const sourcePath = resolvePath(args.resolveDir, args.path); + const outdir = build.initialOptions.outdir + ? resolvePath(build.initialOptions.outdir) + : dirname(resolvePath(build.initialOptions.outfile ?? ".")); + const filename = basename(sourcePath); + const copyPath = resolvePath(outdir, filename); + try { + mkdirSync(outdir, { recursive: true }); + copyFileSync(sourcePath, copyPath); + } catch (err) { + // Surface the failure so the build fails visibly rather + // than producing a binary that crashes at startup. + throw new Error( + `text-import-plugin: failed to copy ${sourcePath} → ${copyPath}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + return { + path: `./${filename}`, + external: true, + }; + } + return null; }); build.onLoad({ filter: ANY_FILTER, namespace: TEXT_IMPORT_NS }, (args) => { const content = readFileSync(args.path, "utf-8"); diff --git a/src/commands/init.ts b/src/commands/init.ts index f1f7dad14..6c2c91111 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -45,6 +45,16 @@ type InitFlags = { readonly "dry-run": boolean; readonly features?: string[]; readonly team?: string; + /** + * Default `true` (Ink is the default UI on the Bun binary). Stricli + * auto-generates a negated `--no-tui` flag that flips this to + * `false` — that's the escape hatch users invoke when the Ink path + * misbehaves (e.g. on unusual terminal emulators). The positive + * `--tui` flag is also accepted for symmetry but is a no-op versus + * the default. On the npm/Node distribution this flag has no + * effect; the factory always picks `LoggingUI` there. + */ + readonly tui: boolean; }; /** @@ -226,6 +236,12 @@ export const initCommand = buildCommand< brief: "Team slug to create the project under", optional: true, }, + tui: { + kind: "boolean", + brief: + "Use the Ink-based interactive UI (default on the Bun binary). Pass --no-tui to fall back to plain log output.", + default: true, + }, }, aliases: { ...DRY_RUN_ALIASES, @@ -285,25 +301,29 @@ export const initCommand = buildCommand< team: flags.team, org: explicitOrg, project: explicitProject, + // `flags.tui` defaults to `true`. `--no-tui` (auto-generated + // by stricli's flag negation) flips it to `false` — that's the + // signal we forward to the factory as `forceLegacyUi`. + forceLegacyUi: flags.tui === false, }); } finally { // 7. macOS-only force-exit safety net. // - // On Darwin, `runWizard` installs the `/dev/tty` forwarding - // workaround from stdin-reopen.ts to get keystrokes through to - // clack. That workaround opens a second `tty.ReadStream` which - // leaks a libuv handle on Bun 1.3.11 — no userland cleanup - // releases it (upstream oven-sh/bun#29126). After `runWizard` - // returns (or throws), the event loop stays ref'd and the process - // hangs until the user presses a key. + // On Darwin, `InkUI` opens a fresh `/dev/tty` `tty.ReadStream` + // (so Ink's `useInput` actually receives keystrokes — Bun's + // `process.stdin` doesn't deliver `readable` events properly, + // see oven-sh/bun#6862 / vadimdemedes/ink#636). The fresh + // stream is destroyed in the InkUI dispose path, but Bun's + // libuv handle for it can linger past `destroy()` on Darwin + // (oven-sh/bun#29126), keeping the event loop ref'd so the + // process hangs until the user presses a key. // // The .unref() timer doesn't hold the loop itself, so it's a no-op - // in the happy path (Linux: no workaround installed, loop drains - // naturally; `--yes` on Darwin: no prompts, no keystroke issue, - // may still drain naturally). On the Darwin hang path, it - // force-exits after a 100ms grace window — imperceptible to the - // user and enough for Sentry telemetry + stdio flushes to - // complete first. + // in the happy path (Linux: handle drains naturally; `--yes` + // on Darwin: LoggingUI doesn't open /dev/tty, may still drain + // naturally). On the Darwin hang path, it force-exits after a + // 100ms grace window — imperceptible to the user and enough + // for Sentry telemetry + stdio flushes to complete first. // // Skipped under `bun test` (which sets NODE_ENV=test automatically) // because the test runner calls `initCommand.func` directly; an diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index 4a135a971..dbcaa2bac 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -1,12 +1,15 @@ /** - * Clack Utilities + * Wizard Utilities * - * Shared helpers for the clack-based init wizard UI. + * Shared cancellation helpers and feature labels for the init wizard. + * + * The file name is preserved (vs. renaming to `wizard-utils.ts`) to + * keep the diff in PR 4 focused on the clack removal — the next + * cleanup PR can do the rename. Despite the historical name nothing + * here references clack any more. */ -import { terminalLink } from "../formatters/colors.js"; -import { cancel, isCancel } from "./clack-plain.js"; -import { SENTRY_DOCS_URL } from "./constants.js"; +import { isCancelled } from "./ui/types.js"; export class WizardCancelledError extends Error { constructor() { @@ -15,14 +18,20 @@ export class WizardCancelledError extends Error { } } -export function abortIfCancelled(value: T | symbol): T { - if (isCancel(value)) { - cancel( - `Setup cancelled. You can visit ${terminalLink(SENTRY_DOCS_URL)} to set up manually.` - ); +/** + * Coerce a possibly-cancelled prompt result into the resolved value, or + * throw `WizardCancelledError` on cancellation. + * + * The return type uses `Exclude` so callers passing a union + * that includes a symbol member (e.g. `string[] | typeof CANCELLED`) + * receive the narrowed non-symbol type back — TypeScript otherwise + * widens `T` to the full union and refuses to call array methods on it. + */ +export function abortIfCancelled(value: T): Exclude { + if (isCancelled(value)) { throw new WizardCancelledError(); } - return value as T; + return value as Exclude; } const FEATURE_INFO: Record = { @@ -114,3 +123,98 @@ export const STEP_LABELS: Record = { "verify-changes": "Verifying changes", "open-sentry-ui": "Finishing up", }; + +/** + * Canonical execution order of the wizard's workflow steps. + * + * Used by the Ink sidebar's progress checklist as the static + * pre-rendered list. The wizard advertises step transitions via + * `WizardUI.setStep(...)`; the store back-fills any earlier + * `pending` rows as `skipped` when a later step starts (the workflow + * can only move forward, so a later transition implies any earlier + * pending step was bypassed by an `if`-branch in the workflow). + * + * Order must match the actual Mastra workflow order or the back-fill + * logic will mis-mark steps as skipped. + */ +export const CANONICAL_STEP_ORDER: readonly string[] = [ + "discover-context", + "select-target-app", + "resolve-dir", + "check-existing-sentry", + "detect-platform", + "ensure-sentry-project", + "select-features", + "install-deps", + "plan-codemods", + "apply-codemods", + "verify-changes", + "open-sentry-ui", +]; + +/** + * Subset of {@link CANONICAL_STEP_ORDER} surfaced in the progress + * checklist. The Ink sidebar is 36 cols wide and shares vertical + * space with the tip card and the files-read panel, so showing all + * 12 step rows would push the files panel off-screen on shorter + * terminals. + * + * The hidden steps (`select-target-app`, `resolve-dir`, + * `check-existing-sentry`) are plumbing — users care that "Setting up + * Sentry project" happened, not that we resolved their working + * directory along the way. + */ +export const CHECKLIST_VISIBLE_STEPS: readonly string[] = [ + "discover-context", + "detect-platform", + "ensure-sentry-project", + "select-features", + "install-deps", + "plan-codemods", + "apply-codemods", + "verify-changes", + "open-sentry-ui", +]; + +/** + * Active-voice step descriptions shown as spinner messages while + * each step runs. More descriptive than the sidebar labels. + */ +export const STEP_ACTIVE_LABELS: Record = { + "discover-context": "Scanning project structure...", + "select-target-app": "Selecting target application...", + "resolve-dir": "Resolving project directory...", + "check-existing-sentry": "Checking for existing Sentry setup...", + "detect-platform": "Detecting framework and platform...", + "ensure-sentry-project": "Configuring Sentry project...", + "select-features": "Preparing feature selection...", + "install-deps": "Installing Sentry SDK and dependencies...", + "plan-codemods": "Planning code changes...", + "apply-codemods": "Applying code modifications...", + "verify-changes": "Verifying setup...", + "open-sentry-ui": "Finishing up...", +}; + +/** + * Sidebar-friendly abbreviations of {@link STEP_LABELS}. The full + * labels stay the source-of-truth for the spinner message in the main + * column; only the 36-col sidebar checklist uses these. + * + * Falls back to the full label if a step isn't listed here. + */ +export const STEP_LABELS_SHORT: Record = { + "discover-context": "Analyzing project", + "detect-platform": "Detecting platform", + "ensure-sentry-project": "Setting up project", + "select-features": "Selecting features", + "install-deps": "Installing deps", + "plan-codemods": "Planning changes", + "apply-codemods": "Applying changes", + "verify-changes": "Verifying changes", + "open-sentry-ui": "Finishing up", +}; + +/** Resolve a step id to its sidebar checklist label. */ +export function shortStepLabel(stepId: string): string { + return STEP_LABELS_SHORT[stepId] ?? STEP_LABELS[stepId] ?? stepId; +} diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index cdf3a590b..43cfeec20 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -1,12 +1,21 @@ /** * Output Formatters * - * Format wizard results and errors for terminal display using clack. + * Translate the raw workflow result into the structured `WizardSummary` + * the UI implementations render. The previous version assembled + * terminal-flavored markdown (color tags, an aligned key/value table, + * a tree of changed files) and pushed it through `ui.log.message`. + * That worked for `LoggingUI` (which calls `renderMarkdown`) but the + * earlier TUI showed literal markup like `~` and + * pipe-cells because the underlying text primitive couldn't parse + * markdown — only strip ANSI. + * + * Now `formatResult` calls `ui.summary(structuredData)` and lets each + * implementation decide how to lay it out. `formatError` still uses + * `ui.log.*` because errors are short enough to live as plain text. */ import { terminalLink } from "../formatters/colors.js"; -import { colorTag, mdKvTable, renderMarkdown } from "../formatters/markdown.js"; -import { cancel, log, outro } from "./clack-plain.js"; import { featureLabel } from "./clack-utils.js"; import { EXIT_DEPENDENCY_INSTALL_FAILED, @@ -14,201 +23,106 @@ import { EXIT_VERIFICATION_FAILED, } from "./constants.js"; import type { WizardOutput, WorkflowRunResult } from "./types.js"; +import type { WizardSummary, WizardUI } from "./ui/types.js"; -type ChangedFile = NonNullable[number]; - -type FileTreeNode = { - name: string; - path?: string; - action?: string; - children: Map; -}; - -function fileActionIcon(action: string): string { - if (action === "create") { - return colorTag("green", "+"); - } - if (action === "delete") { - return colorTag("red", "-"); - } - return colorTag("yellow", "\\~"); -} - -function createFileTreeNode(name: string): FileTreeNode { - return { name, children: new Map() }; -} - -function splitChangedFilePath(filePath: string): string[] { - return filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); -} - -function buildChangedFilesTree(changedFiles: ChangedFile[]): FileTreeNode { - const root = createFileTreeNode(""); - - for (const file of changedFiles) { - const parts = splitChangedFilePath(file.path); - let current = root; - - for (const [index, part] of parts.entries()) { - let child = current.children.get(part); - if (!child) { - child = createFileTreeNode(part); - current.children.set(part, child); - } - - if (index === parts.length - 1) { - child.path = file.path; - child.action = file.action; - } - - current = child; - } - } - - return root; -} - -function sortTreeEntries(entries: FileTreeNode[]): FileTreeNode[] { - return [...entries].sort((left, right) => { - const leftIsDir = left.children.size > 0 && !left.action; - const rightIsDir = right.children.size > 0 && !right.action; - - if (leftIsDir !== rightIsDir) { - return leftIsDir ? -1 : 1; - } - - return left.name.localeCompare(right.name); - }); -} - -function renderChangedFileNode( - node: FileTreeNode, - prefix: string, - isLast: boolean -): string[] { - const lines: string[] = []; - const label = node.action ? node.name : `${node.name}/`; - const branch = isLast ? "└─" : "├─"; - - if (node.action) { - lines.push(`${prefix}${branch} ${fileActionIcon(node.action)} ${label}`); - } else { - lines.push(`${prefix}${branch} ${label}`); - } - - const children = sortTreeEntries([...node.children.values()]); - const childPrefix = `${prefix}${isLast ? " " : "│ "}`; - for (const [index, child] of children.entries()) { - lines.push( - ...renderChangedFileNode( - child, - childPrefix, - index === children.length - 1 - ) - ); - } - - return lines; -} - -function formatChangedFilesTree(changedFiles: ChangedFile[]): string { - const root = buildChangedFilesTree(changedFiles); - const entries = sortTreeEntries([...root.children.values()]); - - return entries - .flatMap((entry, index) => - renderChangedFileNode(entry, "", index === entries.length - 1) - ) - .join("\n"); -} - -function buildSummary(output: WizardOutput): string { - const sections: string[] = []; +/** + * Build the structured summary handed to `ui.summary()`. + * + * Returns `null` when there's nothing useful to display — the caller + * skips the summary call entirely in that case so empty panels don't + * appear. + */ +function buildSummary(output: WizardOutput): WizardSummary | null { + const fields: WizardSummary["fields"] = []; - const kvRows: [string, string][] = []; if (output.platform) { - kvRows.push(["Platform", output.platform]); + fields.push({ label: "Platform", value: output.platform }); } if (output.projectDir) { - kvRows.push(["Directory", output.projectDir]); + fields.push({ label: "Directory", value: output.projectDir }); } if (output.features?.length) { - kvRows.push(["Features", output.features.map(featureLabel).join(", ")]); + fields.push({ + label: "Features", + value: output.features.map(featureLabel).join(", "), + }); } if (output.commands?.length) { - kvRows.push(["Commands", output.commands.join("; ")]); + fields.push({ + label: "Commands", + value: output.commands.join("; "), + }); } if (output.sentryProjectUrl) { - kvRows.push(["Project", output.sentryProjectUrl]); + fields.push({ label: "Project", value: output.sentryProjectUrl }); } if (output.docsUrl) { - kvRows.push(["Docs", output.docsUrl]); + fields.push({ label: "Docs", value: output.docsUrl }); } - if (kvRows.length > 0) { - sections.push(mdKvTable(kvRows)); - } + const changedFiles = output.changedFiles ?? []; - const changedFiles = output.changedFiles; - if (changedFiles?.length) { - sections.push(`Changed files\n${formatChangedFilesTree(changedFiles)}`); + if (fields.length === 0 && changedFiles.length === 0) { + return null; } - return sections.join("\n\n"); + return { + fields, + ...(changedFiles.length > 0 ? { changedFiles } : {}), + }; } -export function formatResult(result: WorkflowRunResult): void { +export function formatResult(result: WorkflowRunResult, ui: WizardUI): void { const output: WizardOutput = result.result ?? {}; - const md = buildSummary(output); + const summary = buildSummary(output); - if (md.length > 0) { - log.message(renderMarkdown(md)); + if (summary) { + ui.summary(summary); } if (output.warnings?.length) { for (const w of output.warnings) { - log.warn(w); + ui.log.warn(w); } } - log.info("Please review the changes above before committing."); - log.info( + ui.log.info("Please review the changes above before committing."); + ui.log.info( "You're one of the first to try the new setup wizard! Run `sentry cli feedback` to let us know how it went." ); - outro("Sentry SDK installed successfully!"); + ui.outro("Sentry SDK installed successfully!"); } -export function formatError(result: WorkflowRunResult): void { +export function formatError(result: WorkflowRunResult, ui: WizardUI): void { const inner = result.result; const message = result.error ?? inner?.message ?? "Wizard failed with an unknown error"; const exitCode = inner?.exitCode ?? 1; - log.error(String(message)); + ui.log.error(String(message)); if (exitCode === EXIT_PLATFORM_NOT_DETECTED) { - log.warn( + ui.log.warn( "Hint: Could not detect your project's platform. Check that the directory contains a valid project." ); } else if (exitCode === EXIT_DEPENDENCY_INSTALL_FAILED) { const commands = inner?.commands; if (commands?.length) { - log.warn( + ui.log.warn( `You can install dependencies manually:\n${commands.map((cmd) => ` $ ${cmd}`).join("\n")}` ); } } else if (exitCode === EXIT_VERIFICATION_FAILED) { - log.warn("Hint: Fix the verification issues and run 'sentry init' again."); + ui.log.warn( + "Hint: Fix the verification issues and run 'sentry init' again." + ); } const docsUrl = inner?.docsUrl; if (docsUrl) { - log.info(`Docs: ${terminalLink(docsUrl)}`); + ui.log.info(`Docs: ${terminalLink(docsUrl)}`); } - cancel("Setup failed"); + ui.cancel("Setup failed"); } diff --git a/src/lib/init/git.ts b/src/lib/init/git.ts index a45fafb34..78e5b0138 100644 --- a/src/lib/init/git.ts +++ b/src/lib/init/git.ts @@ -6,14 +6,17 @@ * * Low-level git primitives live in `src/lib/git.ts`. This module * re-exports them for backward compatibility and adds the interactive - * `checkGitStatus` orchestrator (coupled to `@clack/prompts` UI). + * `checkGitStatus` orchestrator. All UI I/O is routed through the + * injected `WizardUI` so the same code drives `InkUI` (interactive) + * and `LoggingUI` (CI / npm) paths. */ import { getUncommittedFiles, isInsideGitWorkTree as isInsideWorkTree, } from "../git.js"; -import { confirm, isCancel, log } from "./clack-plain.js"; +import type { WizardUI } from "./ui/types.js"; +import { isCancelled } from "./ui/types.js"; /** Maximum number of uncommitted files to display before truncating. */ const MAX_DISPLAYED_FILES = 5; @@ -43,24 +46,25 @@ export function getUncommittedOrUntrackedFiles(opts: { export async function checkGitStatus(opts: { cwd: string; yes: boolean; + ui: WizardUI; }): Promise { - const { cwd, yes } = opts; + const { cwd, yes, ui } = opts; if (!isInsideGitWorkTree({ cwd })) { if (yes) { - log.warn( + ui.log.warn( "You are not inside a git repository. Unable to revert changes if something goes wrong." ); return true; } - const proceed = await confirm({ + const proceed = await ui.confirm({ message: "You are not inside a git repository. Unable to revert changes if something goes wrong. Continue?", }); - if (isCancel(proceed)) { + if (isCancelled(proceed)) { return false; } - return !!proceed; + return Boolean(proceed); } const uncommitted = getUncommittedOrUntrackedFiles({ cwd }); @@ -72,19 +76,19 @@ export async function checkGitStatus(opts: { } const fileList = displayed.join("\n"); if (yes) { - log.warn( + ui.log.warn( `You have uncommitted or untracked files:\n${fileList}\nProceeding anyway (--yes).` ); return true; } - log.warn(`You have uncommitted or untracked files:\n${fileList}`); - const proceed = await confirm({ + ui.log.warn(`You have uncommitted or untracked files:\n${fileList}`); + const proceed = await ui.confirm({ message: "Continue with uncommitted changes?", }); - if (isCancel(proceed)) { + if (isCancelled(proceed)) { return false; } - return !!proceed; + return Boolean(proceed); } return true; diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index d5ac055e5..e3bdcdf2c 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -4,10 +4,13 @@ * Handles interactive prompts from the remote workflow. * Supports select, multi-select, and confirm prompts. * Respects --yes flag for non-interactive mode. + * + * All UI I/O goes through the injected `WizardUI` so the dispatcher + * works identically against `InkUI` (interactive Bun binary) and + * `LoggingUI` (CI / npm fallback). */ import chalk from "chalk"; -import { confirm, log, multiselect, select } from "./clack-plain.js"; import { abortIfCancelled, featureHint, @@ -22,18 +25,20 @@ import type { MultiSelectPayload, SelectPayload, } from "./types.js"; +import type { WizardUI } from "./ui/types.js"; export async function handleInteractive( payload: InteractivePayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { switch (payload.kind) { case "select": - return await handleSelect(payload, options); + return await handleSelect(payload, options, ui); case "multi-select": - return await handleMultiSelect(payload, options); + return await handleMultiSelect(payload, options, ui); case "confirm": - return await handleConfirm(payload, options); + return await handleConfirm(payload, options, ui); default: return { cancelled: true }; } @@ -41,7 +46,8 @@ export async function handleInteractive( async function handleSelect( payload: SelectPayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { const apps = payload.apps ?? []; const items = payload.options ?? apps.map((a) => a.name); @@ -52,23 +58,23 @@ async function handleSelect( if (options.yes) { if (items.length === 1) { - log.info(`Auto-selected: ${items[0]}`); + ui.log.info(`Auto-selected: ${items[0]}`); return { selectedApp: items[0] }; } - log.error( + ui.log.error( `--yes requires exactly one option for selection, but found ${items.length}. Run interactively to choose.` ); return { cancelled: true }; } - const selected = await select({ + const selected = await ui.select({ message: payload.prompt, options: items.map((item, i) => { const app = apps[i]; return { value: item, label: item, - hint: app?.framework ?? undefined, + ...(app?.framework ? { hint: app.framework } : {}), }; }), }); @@ -78,7 +84,8 @@ async function handleSelect( async function handleMultiSelect( payload: MultiSelectPayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { const available = payload.availableFeatures ?? payload.options ?? []; @@ -89,7 +96,7 @@ async function handleMultiSelect( const hasRequired = available.includes(REQUIRED_FEATURE); if (options.yes) { - log.info( + ui.log.info( `Auto-selected all features: ${available.map(featureLabel).join(", ")}` ); return { features: available }; @@ -101,7 +108,7 @@ async function handleMultiSelect( if (optional.length === 0) { if (hasRequired) { - log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`); + ui.log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`); } return { features: hasRequired ? [REQUIRED_FEATURE] : [] }; } @@ -116,13 +123,16 @@ async function handleMultiSelect( } hints.push(`${bar} ${chalk.dim("space=toggle, a=all, enter=confirm")}`); - const selected = await multiselect({ + const selected = await ui.multiselect({ message: `${payload.prompt}\n${hints.join("\n")}`, - options: optional.map((feature) => ({ - value: feature, - label: featureLabel(feature), - hint: featureHint(feature), - })), + options: optional.map((feature) => { + const hint = featureHint(feature); + return { + value: feature, + label: featureLabel(feature), + ...(hint ? { hint } : {}), + }; + }), initialValues: optional.filter((f) => f === "performanceMonitoring"), required: false, }); @@ -137,14 +147,15 @@ async function handleMultiSelect( async function handleConfirm( payload: ConfirmPayload, - options: InteractiveContext + options: InteractiveContext, + ui: WizardUI ): Promise> { if (options.yes) { - log.info("Auto-confirmed: continuing"); + ui.log.info("Auto-confirmed: continuing"); return { action: "continue" }; } - const confirmed = await confirm({ + const confirmed = await ui.confirm({ message: payload.prompt, initialValue: true, }); diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index c6b4a08f1..261bcd8db 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -4,7 +4,6 @@ import { getAuthToken } from "../db/auth.js"; import { WizardError } from "../errors.js"; import { resolveOrCreateTeam } from "../resolve-team.js"; import { slugify } from "../utils.js"; -import { cancel, isCancel, log, select } from "./clack-plain.js"; import { WizardCancelledError } from "./clack-utils.js"; import { tryGetExistingProjectData } from "./existing-project.js"; import { resolveOrgPrefetched } from "./org-prefetch.js"; @@ -13,6 +12,7 @@ import type { ResolvedInitContext, WizardOptions, } from "./types.js"; +import { isCancelled, type WizardUI } from "./ui/types.js"; const NUMERIC_ORG_ID_RE = /^\d+$/; @@ -37,41 +37,48 @@ type ProjectSelection = Pick< * Resolve org, project, team, and auth state before the init workflow starts. */ export async function resolveInitContext( - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { - return await withPreflightHandling(async () => { - const seed = await resolveInitContextSeed(initial); + return await withPreflightHandling(ui, async () => { + const seed = await resolveInitContextSeed(initial, ui); if (!seed) { return null; } - const org = await ensureOrg(seed.org, initial); - const projectSelection = await resolveProjectSelection(org, initial, seed); + const org = await ensureOrg(seed.org, initial, ui); + const projectSelection = await resolveProjectSelection( + org, + initial, + seed, + ui + ); if (!projectSelection) { return null; } - const team = await resolveTeam(org, initial); + const team = await resolveTeam(org, initial, ui); return buildResolvedInitContext(initial, org, team, projectSelection); }); } async function withPreflightHandling( + ui: WizardUI, action: () => Promise ): Promise { try { return await action(); } catch (error) { if (error instanceof WizardCancelledError) { - cancel("Setup cancelled."); + ui.cancel("Setup cancelled."); process.exitCode = 0; return null; } const message = error instanceof Error ? error.message : String(error); - log.error(message); - cancel("Setup failed."); + ui.log.error(message); + ui.cancel("Setup failed."); throw error instanceof WizardError ? error : new WizardError(message); } } @@ -96,9 +103,10 @@ function buildResolvedInitContext( } async function resolveInitContextSeed( - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { - const detected = await resolveDetectedProject(initial); + const detected = await resolveDetectedProject(initial, ui); if (detected?.shouldAbort) { return null; } @@ -112,13 +120,14 @@ async function resolveInitContextSeed( async function ensureOrg( org: string | undefined, - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { if (org) { return org; } - const orgResult = await resolveOrgSlug(initial.directory, initial.yes); + const orgResult = await resolveOrgSlug(initial.directory, initial.yes, ui); if (typeof orgResult === "string") { return orgResult; } @@ -129,7 +138,8 @@ async function ensureOrg( async function resolveProjectSelection( org: string, initial: WizardOptions, - seed: InitContextSeed + seed: InitContextSeed, + ui: WizardUI ): Promise { if (!seed.project) { return { @@ -144,6 +154,7 @@ async function resolveProjectSelection( existingProject: seed.existingProject, yes: initial.yes, promptOnExisting: Boolean(initial.project && !initial.org), + ui, }); if (resolved.shouldAbort) { return null; @@ -168,7 +179,10 @@ function mergeProjectSelection( }; } -async function resolveDetectedProject(initial: WizardOptions): Promise<{ +async function resolveDetectedProject( + initial: WizardOptions, + ui: WizardUI +): Promise<{ org?: string; project?: string; existingProject?: ExistingProjectData; @@ -201,21 +215,21 @@ async function resolveDetectedProject(initial: WizardOptions): Promise<{ }; } - const choice = await select({ + const choice = await ui.select<"existing" | "create">({ message: "Found an existing Sentry project in this codebase.", options: [ { - value: "existing" as const, + value: "existing", label: `Use existing project (${detectedProject.orgSlug}/${detectedProject.projectSlug})`, hint: "Sentry is already configured here", }, { - value: "create" as const, + value: "create", label: "Create a new Sentry project", }, ], }); - if (isCancel(choice)) { + if (isCancelled(choice)) { throw new WizardCancelledError(); } if (choice === "existing") { @@ -235,6 +249,7 @@ async function resolveExistingProjectChoice(opts: { existingProject?: ExistingProjectData; yes: boolean; promptOnExisting: boolean; + ui: WizardUI; }): Promise { const slug = slugify(opts.project); if (!slug) { @@ -258,22 +273,22 @@ async function resolveExistingProjectChoice(opts: { }; } - const choice = await select({ + const choice = await opts.ui.select<"existing" | "create">({ message: `Found existing project '${slug}' in ${opts.org}.`, options: [ { - value: "existing" as const, + value: "existing", label: `Use existing (${opts.org}/${slug})`, hint: "Already configured", }, { - value: "create" as const, + value: "create", label: "Create a new project", hint: "Wizard will detect the project name from your codebase", }, ], }); - if (isCancel(choice)) { + if (isCancelled(choice)) { throw new WizardCancelledError(); } if (choice === "create") { @@ -288,7 +303,8 @@ async function resolveExistingProjectChoice(opts: { async function resolveTeam( org: string, - initial: WizardOptions + initial: WizardOptions, + ui: WizardUI ): Promise { try { const result = await resolveOrCreateTeam(org, { @@ -297,17 +313,17 @@ async function resolveTeam( dryRun: initial.dryRun, deferAutoCreateOnEmptyOrg: true, onAmbiguous: initial.yes - ? async (candidates) => (candidates[0] as SentryTeam).slug + ? (candidates) => Promise.resolve((candidates[0] as SentryTeam).slug) : async (candidates) => { - const selected = await select({ + const selected = await ui.select({ message: "Which team should own this project?", options: candidates.map((team) => ({ value: team.slug, label: team.slug, - hint: team.name !== team.slug ? team.name : undefined, + ...(team.name !== team.slug ? { hint: team.name } : {}), })), }); - if (isCancel(selected)) { + if (isCancelled(selected)) { throw new WizardCancelledError(); } return selected; @@ -326,7 +342,8 @@ async function resolveTeam( async function resolveOrgSlug( cwd: string, - yes: boolean + yes: boolean, + ui: WizardUI ): Promise { const resolved = await resolveOrgPrefetched(cwd); if (resolved && !NUMERIC_ORG_ID_RE.test(resolved.org)) { @@ -352,7 +369,7 @@ async function resolveOrgSlug( }; } - const selected = await select({ + const selected = await ui.select({ message: "Which organization should the project be created in?", options: orgs.map((org) => ({ value: org.slug, @@ -360,7 +377,7 @@ async function resolveOrgSlug( hint: org.slug, })), }); - if (isCancel(selected)) { + if (isCancelled(selected)) { throw new WizardCancelledError(); } return selected; diff --git a/src/lib/init/readiness.ts b/src/lib/init/readiness.ts new file mode 100644 index 000000000..bc208822d --- /dev/null +++ b/src/lib/init/readiness.ts @@ -0,0 +1,78 @@ +/** + * Pre-Flight Readiness Check + * + * Verifies critical dependencies before entering the wizard flow. + * Fails fast with actionable errors instead of failing mid-run. + */ + +import { getAuthToken } from "../db/auth.js"; +import { WizardError } from "../errors.js"; +import { MASTRA_API_URL } from "./constants.js"; +import type { WizardUI } from "./ui/types.js"; + +/** Timeout for the health check fetch (5 seconds). */ +const HEALTH_CHECK_TIMEOUT_MS = 5000; + +/** + * Run pre-flight checks: auth token present, Mastra API reachable. + * Throws `WizardError` on hard failures; logs warnings for soft issues. + */ +export async function checkReadiness(ui: WizardUI): Promise { + const spin = ui.spinner(); + spin.start("Checking prerequisites..."); + + const [authResult, apiResult] = await Promise.allSettled([ + checkAuth(), + checkMastraApi(), + ]); + + const authOk = authResult.status === "fulfilled" && authResult.value; + const apiOk = apiResult.status === "fulfilled" && apiResult.value; + + if (!(authOk || apiOk)) { + spin.stop("Prerequisites failed", 1); + ui.log.error("Authentication and setup service are both unavailable."); + ui.log.info("Run `sentry auth login` to authenticate."); + ui.log.info("Check your network connection and try again."); + ui.cancel("Setup failed"); + throw new WizardError("Pre-flight checks failed"); + } + + if (!authOk) { + spin.stop("Prerequisites failed", 1); + ui.log.error("No authentication token found."); + ui.log.info("Run `sentry auth login` to authenticate, then try again."); + ui.cancel("Setup failed"); + throw new WizardError("Not authenticated"); + } + + if (apiOk) { + spin.stop("Prerequisites OK"); + } else { + spin.stop("Warning", 2); + ui.log.warn( + "Setup service may be slow or unreachable. The wizard will retry if needed." + ); + } +} + +async function checkAuth(): Promise { + const token = await getAuthToken(); + return token !== undefined && token !== ""; +} + +async function checkMastraApi(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); + try { + const resp = await fetch(`${MASTRA_API_URL}/health`, { + signal: controller.signal, + method: "GET", + }); + return resp.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 182b3a58b..1b811203e 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -20,6 +20,15 @@ export type WizardOptions = { team?: string; org?: string; project?: string; + /** + * Force the non-Ink fallback (`LoggingUI`). Mapped from + * `--no-tui`. Acts as an escape hatch when the Ink TUI + * misbehaves; in an interactive run this effectively disables + * prompts (any prompt path will throw a `LoggingUIPromptError`), + * so users hitting this flag should also pass `--yes` or set + * every choice via flags. + */ + forceLegacyUi?: boolean; }; export type ResolvedInitContext = { diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts new file mode 100644 index 000000000..ff363d390 --- /dev/null +++ b/src/lib/init/ui/factory.ts @@ -0,0 +1,130 @@ +/** + * WizardUI Factory + * + * Picks the appropriate `WizardUI` implementation based on runtime + * environment and CLI flags. This is the single chokepoint for UI + * selection — every part of the init wizard goes through `getUIAsync()` + * rather than instantiating implementations directly. + * + * Selection priority (highest first): + * + * 1. `--yes` flag set, OR stdin/stdout is not a TTY — `LoggingUI` + * (CI / piped input). Prompt methods throw, so callers must + * pre-resolve every choice up-front. + * 2. `SENTRY_INIT_TUI=0` or `--no-tui` — `LoggingUI`. Acts as a debug + * escape hatch when the Ink path misbehaves. In an interactive + * context this means the wizard becomes effectively non-interactive + * (any prompt aborts), so users hitting this path will need to set + * every choice via flags or rely on auto-detection. + * 3. Running outside the Bun-compiled binary (i.e. on Node) — also + * `LoggingUI`. Ink uses top-level await in its reconciler and the + * `yoga-layout` dependency, which esbuild can't emit in our CJS + * bundle, so the npm distribution can't load Ink at runtime. The + * Bun binary embeds Ink + React + ink-app.tsx via + * `with { type: "file" }`, sidestepping the bundler entirely. The + * npm package's `--help` output and onboarding docs direct users + * to the Bun binary for the interactive `sentry init` experience. + * 4. Default (Bun binary, interactive, no opt-out) — `InkUI`. + * + * Implementation history: + * - PR 4: replaced `ClackUI` with `OpenTuiUI` as the default. + * - This PR: replaced `OpenTuiUI` with `InkUI`. OpenTUI's Zig + * bindings added ~10.7 MB to the binary; Ink + React + companions + * add a fraction of that and use no native code. + */ + +import { LoggingUI } from "./logging-ui.js"; +import type { WizardUI } from "./types.js"; + +/** + * Inputs that affect UI selection. Mirrors the relevant subset of + * `WizardOptions` so we don't drag the full type into the factory. + */ +export type UIFactoryOptions = { + /** True when `--yes` (or `--dry-run`, which implies non-interactive) is set. */ + yes: boolean; + /** + * True when the user explicitly opted out of the new TUI via + * `--no-tui`. Forces `LoggingUI`. + */ + forceLegacy?: boolean; +}; + +/** + * Detect whether the CLI is running inside the Bun-compiled binary + * (where the embedded `ink-app.tsx` resource is reachable) vs. the + * npm/Node distribution. The `Bun` global only exists in the Bun + * runtime. + * + * Exported for the test suite — production callers should go through + * `getUIAsync()`. + */ +export function isBunRuntime(): boolean { + return ( + typeof globalThis.Bun !== "undefined" && + typeof process.versions.bun === "string" + ); +} + +/** + * Detect whether the current process can run an interactive prompt. + * Both stdin (read keystrokes) and stdout (render the prompt) must be + * TTYs. Piped input or output disqualifies us. + * + * Exported for the test suite. + */ +export function isInteractiveTerminal(): boolean { + return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); +} + +/** + * Returns `true` when the `LoggingUI` should be used — i.e. we're in + * a non-interactive context, the user opted out of the TUI, the env + * var override is set, or the runtime can't load Ink. + */ +function shouldUseLogging(opts: UIFactoryOptions): boolean { + if (process.env.SENTRY_INIT_TUI === "0") { + return true; + } + if (opts.forceLegacy) { + return true; + } + if (opts.yes) { + return true; + } + if (!isInteractiveTerminal()) { + return true; + } + if (!isBunRuntime()) { + return true; + } + return false; +} + +/** + * Async factory — picks `InkUI` for interactive runs on the Bun + * binary, otherwise `LoggingUI`. The async form exists because + * instantiating `InkUI` requires a lazy `import("ink")` (the package + * isn't bundled into the npm/Node distribution and would fail to + * resolve if statically imported there). + * + * Callers should treat the return value as an `AsyncDisposable` and + * use `await using ui = await getUIAsync(...)` to guarantee teardown + * on every exit path. + */ +export async function getUIAsync(opts: UIFactoryOptions): Promise { + if (shouldUseLogging(opts)) { + return new LoggingUI(); + } + try { + const { createInkUI } = await import("./ink-ui.js"); + return await createInkUI(); + } catch { + // Fall through to LoggingUI so a missing/broken Ink install + // doesn't take down the wizard. This branch should be + // unreachable on a correctly built Bun binary — it exists as + // a safety net for unusual runtime environments where the + // import fails. + return new LoggingUI(); + } +} diff --git a/src/lib/init/ui/file-tree.ts b/src/lib/init/ui/file-tree.ts new file mode 100644 index 000000000..84790ee38 --- /dev/null +++ b/src/lib/init/ui/file-tree.ts @@ -0,0 +1,239 @@ +/** + * Changed-files tree builder. + * + * Both `InkUI`'s React `` / `` and + * `LoggingUI.summary()` (plus the post-dispose chalk report) want a + * nested directory tree view of the wizard's changed files — + * collapses common prefixes and makes the actual scope of edits + * visible at a glance. + * + * The pre-React formatter built this with `colorTag()` markdown tags + * (`+`); the TUI couldn't render those because the + * text renderer stripped ANSI/markdown. Keeping the tree as pure + * data plus a flat render-list lets each renderer attach its own + * colors / box-drawing. + */ + +export type ChangedFile = { + action: string; + path: string; +}; + +/** + * One entry in the read-files tree. `status` mirrors the + * `FileReadEntry.status` shape from the wizard store so the Ink + * `FilesPanel` can render an at-a-glance icon per row. + */ +export type ReadFile = { + path: string; + status: "reading" | "analyzed"; +}; + +export type FileTreeNode = { + /** Path segment for this node (e.g. "src", "router.tsx"). */ + name: string; + /** + * Full file path relative to the project root. Set only on leaf + * (file) nodes. Directory nodes leave this `undefined`. + */ + path?: string; + /** Action recorded by the workflow — only on leaf nodes. */ + action?: string; + /** + * Read-progress status for the leaf — only set when the tree is + * built from read entries (vs. changed files, which carry `action` + * instead). Mutually exclusive with {@link FileTreeNode.action} in + * practice; consumers branch on whichever is populated. + */ + status?: "reading" | "analyzed"; + children: FileTreeNode[]; +}; + +/** + * Flat row produced by `flattenTree()` — one per visible line in the + * rendered output. Carries everything a renderer needs to draw a + * single row without re-walking the tree. + */ +export type FileTreeRow = { + /** Box-drawing prefix for ancestor pipes (e.g. `"│ │ "`). */ + prefix: string; + /** Branch glyph for this row — `"├─"` or `"└─"`. */ + branch: string; + /** + * `"file"` if this row represents a leaf (with action + path); + * `"directory"` otherwise. Renderers use this to decide whether to + * draw the action glyph cell. + */ + kind: "file" | "directory"; + /** Display name. Directories get a trailing `/`. */ + label: string; + /** Full path — only set on `file` rows. */ + path?: string; + /** Action — only set on `file` rows from a changed-files tree. */ + action?: string; + /** + * Read-progress status — only set on `file` rows from a read-files + * tree. Mutually exclusive with `action` in practice. + */ + status?: "reading" | "analyzed"; +}; + +function splitPath(filePath: string): string[] { + return filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); +} + +/** + * Build a directory tree from the flat changed-files list. Files + * sharing a common prefix collapse into nested directories. + */ +export function buildFileTree(files: ChangedFile[]): FileTreeNode { + const root: FileTreeNode = { name: "", children: [] }; + + // Maintain a parallel map keyed by parent reference so we can do + // O(1) lookups for "does this directory already have a child named + // X?" without scanning each parent's children array. + const childIndex = new WeakMap>(); + childIndex.set(root, new Map()); + + for (const file of files) { + const parts = splitPath(file.path); + let current = root; + + for (const [index, part] of parts.entries()) { + const map = childIndex.get(current) ?? new Map(); + let child = map.get(part); + if (!child) { + child = { name: part, children: [] }; + map.set(part, child); + childIndex.set(current, map); + childIndex.set(child, new Map()); + current.children.push(child); + } + + if (index === parts.length - 1) { + child.path = file.path; + child.action = file.action; + } + + current = child; + } + } + + sortRecursive(root); + return root; +} + +/** + * Sort the tree in place: directories before files at each level, + * then alphabetical within each group. Matches the legacy formatter's + * ordering so existing screenshots/snapshots stay valid. + */ +function sortRecursive(node: FileTreeNode): void { + node.children.sort((left, right) => { + const leftIsDir = left.children.length > 0 && !left.action; + const rightIsDir = right.children.length > 0 && !right.action; + if (leftIsDir !== rightIsDir) { + return leftIsDir ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); + for (const child of node.children) { + sortRecursive(child); + } +} + +/** + * Walk the tree and emit one {@link FileTreeRow} per line, ready to + * be fed into a renderer. Directory nodes appear before their + * children with the appropriate box-drawing prefix. + */ +export function flattenTree(root: FileTreeNode): FileTreeRow[] { + const rows: FileTreeRow[] = []; + walk(root.children, "", rows); + return rows; +} + +function walk( + nodes: FileTreeNode[], + prefix: string, + rows: FileTreeRow[] +): void { + for (const [index, node] of nodes.entries()) { + const isLast = index === nodes.length - 1; + rows.push(rowFor(node, prefix, isLast)); + if (node.children.length > 0) { + const childPrefix = `${prefix}${isLast ? " " : "│ "}`; + walk(node.children, childPrefix, rows); + } + } +} + +function rowFor( + node: FileTreeNode, + prefix: string, + isLast: boolean +): FileTreeRow { + // Files are leaves that carry either a change `action` (from + // `buildFileTree`) or a read `status` (from `buildReadTree`). A + // node with neither but a `path` set is also a file — covers + // future tree builders that don't tag leaves. + const isFile = + Boolean(node.action) || + Boolean(node.status) || + (node.path !== undefined && node.children.length === 0); + return { + prefix, + branch: isLast ? "└─" : "├─", + kind: isFile ? "file" : "directory", + label: isFile ? node.name : `${node.name}/`, + ...(node.path !== undefined ? { path: node.path } : {}), + ...(node.action !== undefined ? { action: node.action } : {}), + ...(node.status !== undefined ? { status: node.status } : {}), + }; +} + +/** + * Build a directory tree from the wizard's read-files list. Mirrors + * {@link buildFileTree} but tags leaves with `status` instead of + * `action`. + * + * Insertion order is preserved (no sort) so newly-read files always + * land at the bottom of their parent directory — gives the Ink + * `FilesPanel`'s tail-window viewport a stable "tail -f" feel. + */ +export function buildReadTree(files: ReadFile[]): FileTreeNode { + const root: FileTreeNode = { name: "", children: [] }; + const childIndex = new WeakMap>(); + childIndex.set(root, new Map()); + + for (const file of files) { + const parts = splitPath(file.path); + let current = root; + + for (const [index, part] of parts.entries()) { + const map = childIndex.get(current) ?? new Map(); + let child = map.get(part); + if (!child) { + child = { name: part, children: [] }; + map.set(part, child); + childIndex.set(current, map); + childIndex.set(child, new Map()); + current.children.push(child); + } + + if (index === parts.length - 1) { + child.path = file.path; + child.status = file.status; + } + + current = child; + } + } + + // Deliberately no `sortRecursive(root)` — keep insertion order so + // sticky-bottom scrollbox tracking feels right. + return root; +} diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx new file mode 100644 index 000000000..3087625ac --- /dev/null +++ b/src/lib/init/ui/ink-app.tsx @@ -0,0 +1,1209 @@ +/** + * InkUI React App — Full-Screen Wizard + * + * Renders the wizard in alternate-screen mode using Ink. The layout + * fills the terminal: + * + * ┌─ ◆ Sentry Init Wizard ──────────────────── sentry.io ─┐ + * │ │ + * │ ╔═══╗ │ ╭ Did you know? ─────────╮ │ + * │ ║ S ║ Sentry banner │ │ │ │ + * │ ╚═══╝ │ ╰────────────────────────╯ │ + * │ ● log line │ │ + * │ ▲ log line │ ╭ Tasks ────── 2/9 ──────╮ │ + * │ ◐ spinner... │ │ ◼ Discover ctx │ │ + * │ [PromptArea] │ │ ▶ Install deps │ │ + * │ │ │ ◻ Apply codemods │ │ + * │ │ ╰────────────────────────╯ │ + * │ ────────────────────────────────────────────────────── │ + * │ ◆ Reading package.json │ + * │ ● Status Files │ + * │ ←→ switch tab s toggle status │ + * └─────────────────────────────────────────────────────────┘ + * + * Tab 1 (Status): Banner + logs + spinner + prompts + summary + * Tab 2 (Files): Scrollable file read tree + */ + +import { Box, Text, useInput, useStdout } from "ink"; +import Spinner from "ink-spinner"; +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + buildFileTree, + buildReadTree, + type FileTreeRow, + flattenTree, +} from "./file-tree.js"; +import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; +import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; +import type { WizardSummary } from "./types.js"; +import type { + ActivePrompt, + FileReadEntry, + LearnState, + LogEntry, + LogSeverity, + SpinnerState, + StepEntry, + WizardStore, +} from "./wizard-store.js"; + +// ──────────────────────────── Visual constants ──────────────────────── + +/** Sentry blurple — primary brand accent. */ +const ACCENT = "#7553FF"; +const MUTED = "gray"; +const MUTED_DIM = "#555555"; +/** Sentry purple — spinners, in-progress states. */ +const PRIMARY = "#8B6AC8"; + +const COLOR_INFO = "#9C84D4"; +const COLOR_WARN = "#FDB81B"; +const COLOR_ERROR = "#fe4144"; +const COLOR_SUCCESS = "#83da90"; + +const MIN_WIDTH = 80; +const MAX_WIDTH = 120; + +/** Number of collapsed status-bar lines visible. */ +const STATUS_COLLAPSED_COUNT = 2; +/** Number of expanded status-bar lines visible. */ +const STATUS_EXPANDED_COUNT = 10; + +const ICON_BY_SEVERITY: Record = + { + info: { glyph: "●", color: COLOR_INFO }, + warn: { glyph: "▲", color: COLOR_WARN }, + error: { glyph: "✖", color: COLOR_ERROR }, + success: { glyph: "✔", color: COLOR_SUCCESS }, + message: { glyph: " ", color: "white" }, + }; + +const ICONS = { + diamond: "\u25C6", + diamondOpen: "\u25C7", + separator: "\u250A", + verticalLine: "\u2502", + squareFilled: "\u25FC", + squareOpen: "\u25FB", + triangleRight: "\u25B6", + triangleSmallRight: "\u25B8", + bullet: "\u2022", +} as const; + +// ────────────────────────────── App entry ───────────────────────────── + +export type AppProps = { + store: WizardStore; +}; + +export function App({ store }: AppProps): React.ReactNode { + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot + ); + const { columns, rows } = useTerminalSize(); + const [activeTab, setActiveTab] = useState(0); + + const width = getContentWidth(columns); + const contentHeight = Math.max(5, rows - 3); + const isWide = width >= 80; + + useInput((input, key) => { + if (key.ctrl && input === "c" && !snapshot.prompt) { + snapshot.requestCancel?.(); + return; + } + if (key.leftArrow && !snapshot.prompt) { + setActiveTab((prev) => Math.max(0, prev - 1)); + return; + } + if (key.rightArrow && !snapshot.prompt) { + setActiveTab((prev) => Math.min(1, prev + 1)); + return; + } + if (input === "s" && !snapshot.prompt) { + store.toggleStatusExpanded(); + } + }); + + const statusMessages = snapshot.statusMessages; + const visibleCount = snapshot.statusExpanded + ? STATUS_EXPANDED_COUNT + : STATUS_COLLAPSED_COUNT; + const visibleMessages = statusMessages.slice(-visibleCount); + + const tabs = useMemo( + () => [ + { id: "status", label: "Status" }, + { id: "files", label: "Files" }, + ], + [] + ); + + const hints: KeyHint[] = useMemo(() => { + const h: KeyHint[] = [{ label: "\u2190\u2192", action: "switch tab" }]; + if (statusMessages.length > STATUS_COLLAPSED_COUNT) { + h.push({ label: "s", action: "toggle status" }); + } + if (activeTab === 1 && snapshot.filesRead.length > 0) { + h.push({ label: "\u2191\u2193", action: "scroll" }); + } + if (snapshot.prompt) { + if (snapshot.prompt.kind === "confirm") { + h.push({ label: "y/n", action: "answer" }); + } else { + h.push({ label: "\u2191\u2193", action: "navigate" }); + h.push({ label: "enter", action: "confirm" }); + h.push({ label: "esc", action: "cancel" }); + } + } + return h; + }, [ + statusMessages.length, + snapshot.prompt, + activeTab, + snapshot.filesRead.length, + ]); + + const marginLeft = Math.max(0, Math.floor((columns - width) / 2)); + + const inner = ( + + + + + + {activeTab === 0 ? ( + + ) : ( + + )} + + {isWide ? ( + + ) : null} + + + {snapshot.overlay ? ( + + ) : null} + + {visibleMessages.length > 0 ? ( + + ) : null} + + + + + + + ); + + return inner; +} + +// ────────────────────────────── Layout helpers ──────────────────────── + +function getContentWidth(terminalColumns: number): number { + if (terminalColumns < MIN_WIDTH) { + return terminalColumns; + } + return Math.min(MAX_WIDTH, terminalColumns); +} + +function useTerminalSize(): { columns: number; rows: number } { + const { stdout } = useStdout(); + const [size, setSize] = useState(() => ({ + columns: stdout?.columns ?? 80, + rows: stdout?.rows ?? 24, + })); + useEffect(() => { + if (!stdout) { + return; + } + const onResize = () => { + setSize({ + columns: stdout.columns ?? 80, + rows: stdout.rows ?? 24, + }); + }; + stdout.on("resize", onResize); + return () => { + stdout.off("resize", onResize); + }; + }, [stdout]); + return size; +} + +// ──────────────────────────── Status Bar ────────────────────────────── + +function StatusBar({ messages }: { messages: string[] }): React.ReactNode { + return ( + + {messages.map((msg, i, arr) => { + const isCurrent = i === arr.length - 1; + // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression + const msgColor = isCurrent ? MUTED : MUTED_DIM; + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional status messages + + {isCurrent ? ICONS.diamond : ICONS.separator} {msg} + + ); + })} + + ); +} + +// ──────────────────────────── Tab Bar ───────────────────────────────── + +function TabBar({ + tabs, + activeTab, +}: { + tabs: { id: string; label: string }[]; + activeTab: number; +}): React.ReactNode { + return ( + + {tabs.map((tab, i) => { + const isActive = i === activeTab; + // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression + const tabColor = isActive ? ACCENT : MUTED_DIM; + return ( + + + {isActive ? ICONS.bullet : " "} {tab.label} + + + ); + })} + + ); +} + +// ────────────────────────── Keyboard Hints ──────────────────────────── + +type KeyHint = { label: string; action: string }; + +function KeyboardHintsBar({ hints }: { hints: KeyHint[] }): React.ReactNode { + return ( + + {hints.map((hint, i) => ( + + + {hint.label} + + {hint.action} + + ))} + + ); +} + +// ────────────────────────────── Sidebar ─────────────────────────────── + +function Sidebar({ + learnState, + steps, + terminalRows, + tipIndex, +}: { + learnState: LearnState; + steps: StepEntry[]; + terminalRows: number; + tipIndex: number; +}): React.ReactNode { + const showTips = terminalRows >= 24; + return ( + + {showTips ? ( + <> + {learnState.complete ? ( + + ) : ( + + )} + + + ) : null} + + + ); +} + +// ─────────────────────────── Activity Pane ──────────────────────────── + +function ActivityPane({ + bannerRows, + logs, + spinner, + prompt, + summary, +}: { + bannerRows: { content: string; color: string }[]; + logs: LogEntry[]; + spinner: SpinnerState; + prompt: ActivePrompt | null; + summary: WizardSummary | null; +}): React.ReactNode { + const hasContent = + logs.length > 0 || spinner.active || prompt !== null || summary !== null; + + return ( + + {bannerRows.length > 0 ? ( + + {bannerRows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional banner rows + + {row.content} + + ))} + + ) : null} + {!hasContent && bannerRows.length === 0 ? ( + + + + + + Initializing wizard... + + + ) : null} + {logs.length > 0 ? ( + + {logs.map((log) => ( + + ))} + + ) : null} + {spinner.active ? : null} + {summary ? : null} + {prompt ? : null} + + ); +} + +// ─────────────────────────── Files Screen ───────────────────────────── + +function FilesScreen({ + filesRead, + hasActivePrompt, + terminalRows, +}: { + filesRead: FileReadEntry[]; + hasActivePrompt: boolean; + terminalRows: number; +}): React.ReactNode { + if (filesRead.length === 0) { + return ( + + No files read yet... + + ); + } + + return ( + + + + ); +} + +// ──────────────────────────── Outro Screen ──────────────────────────── + +// ──────────────────────────── Overlay ───────────────────────────────── + +function OverlayPanel({ + overlay, +}: { + overlay: NonNullable; +}): React.ReactNode { + return ( + + + + {overlay.message} + + {overlay.retryCount > 0 ? ( + + Retry {overlay.retryCount}... + + ) : null} + + ); +} + +// ──────────────────────────── Components ────────────────────────────── + +function LogLine({ entry }: { entry: LogEntry }): React.ReactNode { + const { glyph, color } = ICON_BY_SEVERITY[entry.severity]; + return ( + + + {glyph} + + {entry.text} + + ); +} + +function SpinnerRow({ state }: { state: SpinnerState }): React.ReactNode { + return ( + + + + + + + {state.message} + + ); +} + +// ──────────────────────────── Tip Panel ────────────────────────────── + +function TipPanel({ tipIndex }: { tipIndex: number }): React.ReactNode { + const tip = SENTRY_TIPS[tipIndex % SENTRY_TIPS.length] as SentryTip; + const total = SENTRY_TIPS.length; + const oneIndexed = (tipIndex % total) + 1; + return ( + + + {ICONS.diamondOpen} Did you know? + + + + {tip.title} + + {tip.body} + + + + {oneIndexed}/{total} + + + + ); +} + +// ─────────────────────────── Learn Panel ────────────────────────────── + +function LearnPanel({ + learnState, +}: { + learnState: LearnState; +}): React.ReactNode { + const block = LEARN_SEQUENCE[learnState.blockIndex]; + if (!block) { + return null; + } + // Pad short blocks to BLOCK_LINE_COUNT so height stays fixed. + const lines = block.lines.slice(0, BLOCK_LINE_COUNT); + const padding = Math.max(0, BLOCK_LINE_COUNT - lines.length); + return ( + + + + {block.title} + + + {learnState.blockIndex + 1}/{LEARN_SEQUENCE.length} + + + + {lines.map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional content lines + {line || " "} + ))} + {Array.from({ length: padding }, (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional filler + + ))} + + ); +} + +// ────────────────────────── Progress Panel ──────────────────────────── + +function ProgressPanel({ steps }: { steps: StepEntry[] }): React.ReactNode { + const completedCount = steps.filter( + (entry) => entry.status === "completed" + ).length; + const totalCount = steps.length; + + const headerRight = totalCount > 0 ? `${completedCount}/${totalCount}` : ""; + const badgeColor = completedCount === totalCount ? COLOR_SUCCESS : MUTED_DIM; + + return ( + + + + {ICONS.diamondOpen} Tasks + + {headerRight ? {headerRight} : null} + + + {steps.length === 0 ? ( + + + + + Analyzing project... + + ) : null} + {steps.map((entry) => ( + + ))} + + ); +} + +function ProgressRow({ entry }: { entry: StepEntry }): React.ReactNode { + const { glyph, glyphColor, labelColor, dimLabel } = progressStyle(entry); + return ( + + + {glyph} + + + {entry.label} + + + ); +} + +function progressStyle(entry: StepEntry): { + glyph: string; + glyphColor: string; + labelColor: string; + dimLabel: boolean; +} { + if (entry.status === "in_progress") { + return { + glyph: ICONS.triangleRight, + glyphColor: PRIMARY, + labelColor: "white", + dimLabel: false, + }; + } + if (entry.status === "completed") { + return { + glyph: ICONS.squareFilled, + glyphColor: COLOR_SUCCESS, + labelColor: MUTED, + dimLabel: false, + }; + } + if (entry.status === "failed") { + return { + glyph: "\u2716", + glyphColor: COLOR_ERROR, + labelColor: COLOR_ERROR, + dimLabel: false, + }; + } + if (entry.status === "skipped") { + return { + glyph: "\u25CC", + glyphColor: MUTED_DIM, + labelColor: MUTED_DIM, + dimLabel: true, + }; + } + return { + glyph: ICONS.squareOpen, + glyphColor: MUTED_DIM, + labelColor: MUTED, + dimLabel: true, + }; +} + +// ─────────────────────────── Files Panel ────────────────────────────── + +function FilesPanel({ + filesRead, + maxRows, + hasActivePrompt, +}: { + filesRead: FileReadEntry[]; + maxRows: number; + hasActivePrompt: boolean; +}): React.ReactNode { + const [pinnedToBottom, setPinnedToBottom] = useState(true); + const [offset, setOffset] = useState(0); + + const tree = buildReadTree(filesRead); + const rows = flattenTree(tree); + const totalRows = rows.length; + + const viewport = Math.max(1, maxRows - 1); + const canScroll = totalRows > viewport; + + const maxOffset = Math.max(0, totalRows - viewport); + const effectiveOffset = pinnedToBottom ? 0 : Math.min(offset, maxOffset); + + const sliceEnd = totalRows - effectiveOffset; + const sliceStart = Math.max(0, sliceEnd - viewport); + const visible = rows.slice(sliceStart, sliceEnd); + + const prevTotalRef = useRef(totalRows); + useEffect(() => { + const prev = prevTotalRef.current; + prevTotalRef.current = totalRows; + if (pinnedToBottom) { + return; + } + const newMax = Math.max(0, totalRows - viewport); + if (totalRows > prev) { + setOffset((current) => Math.min(newMax, current + (totalRows - prev))); + } else if (totalRows < prev) { + setOffset((current) => Math.min(current, newMax)); + } + }, [totalRows, viewport, pinnedToBottom]); + + useInput( + (_input, key) => { + if (!canScroll) { + return; + } + if (key.upArrow) { + setPinnedToBottom(false); + setOffset((current) => Math.min(maxOffset, current + 1)); + return; + } + if (key.downArrow) { + setOffset((current) => { + const next = Math.max(0, current - 1); + if (next === 0) { + setPinnedToBottom(true); + } + return next; + }); + return; + } + if (key.pageUp) { + setPinnedToBottom(false); + setOffset((current) => Math.min(maxOffset, current + viewport)); + return; + } + if (key.pageDown) { + setOffset((current) => { + const next = Math.max(0, current - viewport); + if (next === 0) { + setPinnedToBottom(true); + } + return next; + }); + return; + } + if (key.home) { + setPinnedToBottom(false); + setOffset(maxOffset); + return; + } + if (key.end) { + setPinnedToBottom(true); + setOffset(0); + } + }, + { isActive: !hasActivePrompt } + ); + + if (filesRead.length === 0) { + return null; + } + + const analyzedCount = filesRead.filter( + (entry) => entry.status === "analyzed" + ).length; + const padding = Math.max(0, viewport - visible.length); + + return ( + + + + Files analyzed + + + {pinnedToBottom ? "" : "\u2191 "} + {analyzedCount}/{filesRead.length} + + + + + + {visible.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional read-tree rows + + ))} + {Array.from({ length: padding }, (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional filler + + ))} + + {canScroll ? ( + + ) : null} + + + ); +} + +function Scrollbar({ + offset, + totalRows, + viewport, +}: { + offset: number; + totalRows: number; + viewport: number; +}): React.ReactNode { + const maxOff = Math.max(1, totalRows - viewport); + const thumbSize = Math.max(1, Math.floor((viewport * viewport) / totalRows)); + const trackSpan = Math.max(1, viewport - thumbSize); + const thumbStart = Math.round(((maxOff - offset) / maxOff) * trackSpan); + const cells = Array.from({ length: viewport }, (_v, i) => { + const inThumb = i >= thumbStart && i < thumbStart + thumbSize; + return inThumb ? "\u2588" : ICONS.verticalLine; + }); + return ( + + {cells.map((cell, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional scrollbar + + {cell} + + ))} + + ); +} + +function ReadTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { + if (row.kind === "directory") { + return ( + + {`${row.prefix}${row.branch} `} + {row.label} + + ); + } + const { glyph, glyphColor, labelColor } = readStatusStyle(row.status); + return ( + + {`${row.prefix}${row.branch} `} + {`${glyph} `} + {row.label} + + ); +} + +function readStatusStyle(status: FileTreeRow["status"]): { + glyph: string; + glyphColor: string; + labelColor: string; +} { + if (status === "reading") { + return { glyph: "\u25D0", glyphColor: PRIMARY, labelColor: "white" }; + } + return { glyph: "\u2713", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; +} + +// ────────────────────────────── Summary ─────────────────────────────── + +function SummaryPanel({ + summary, +}: { + summary: WizardSummary; +}): React.ReactNode { + return ( + + {summary.fields.length > 0 ? ( + + {summary.fields.map((field) => ( + + + {field.label} + + {field.value} + + ))} + + ) : null} + {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( + + ) : null} + + ); +} + +function ChangedFilesTree({ + files, +}: { + files: { action: string; path: string }[]; +}): React.ReactNode { + const tree = buildFileTree(files); + const treeRows = flattenTree(tree); + return ( + + + Changed files + + {treeRows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional tree rows + + ))} + + ); +} + +function FileTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { + if (row.kind === "directory") { + return ( + + {`${row.prefix}${row.branch} `} + {row.label} + + ); + } + const { glyph, color } = changedFileStyle(row.action ?? "modify"); + return ( + + {`${row.prefix}${row.branch} `} + {`${glyph} `} + {row.label} + + ); +} + +function changedFileStyle(action: string): { glyph: string; color: string } { + if (action === "create") { + return { glyph: "+", color: COLOR_SUCCESS }; + } + if (action === "delete") { + return { glyph: "\u2212", color: COLOR_ERROR }; + } + return { glyph: "~", color: COLOR_WARN }; +} + +// ─────────────────────────────── Prompts ────────────────────────────── + +function PromptArea({ prompt }: { prompt: ActivePrompt }): React.ReactNode { + if (prompt.kind === "select") { + return ; + } + if (prompt.kind === "confirm") { + return ; + } + return ; +} + +function SelectPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + const totalCount = prompt.options.length; + const [highlighted, setHighlighted] = useState(() => + Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1)) + ); + + useInput((input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + if (key.downArrow) { + setHighlighted((idx) => (idx + 1) % totalCount); + return; + } + if (key.escape || (key.ctrl && input === "c")) { + prompt.resolve(null); + return; + } + if (key.return) { + const current = prompt.options[highlighted]; + if (current) { + prompt.resolve(current.value); + } + } + }); + + return ( + + + + {ICONS.diamondOpen} + + {prompt.message} + + + {prompt.options.map((option, idx) => { + const isCursor = idx === highlighted; + // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression + const labelColor = isCursor ? "white" : MUTED; + return ( + + + + {isCursor ? ICONS.triangleSmallRight : " "} + + + + {option.label} + + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); + })} + + + ); +} + +function ConfirmPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + useInput((input, key) => { + if (input === "y" || input === "Y") { + prompt.resolve(true); + return; + } + if (input === "n" || input === "N") { + prompt.resolve(false); + return; + } + if (key.return) { + prompt.resolve(prompt.initialValue); + return; + } + if (key.escape || (key.ctrl && input === "c")) { + prompt.resolve(null); + } + }); + + const yLabel = prompt.initialValue ? "Y" : "y"; + const nLabel = prompt.initialValue ? "n" : "N"; + + return ( + + + + {ICONS.diamondOpen} + + {prompt.message} + + ({yLabel}/{nLabel}) + + + + ); +} + +function MultiSelectPrompt({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + const [selected, setSelected] = useState>( + () => new Set(prompt.initialSelected) + ); + const [highlighted, setHighlighted] = useState(0); + const totalCount = prompt.options.length; + + const toggleAt = (idx: number) => { + const current = prompt.options[idx]; + if (!current) { + return; + } + setSelected((prev) => { + const next = new Set(prev); + if (next.has(current.value)) { + next.delete(current.value); + } else { + next.add(current.value); + } + return next; + }); + }; + + const commit = () => { + if (prompt.required && selected.size === 0) { + return; + } + const ordered = prompt.options + .map((option) => option.value) + .filter((value) => selected.has(value)); + prompt.resolve(ordered); + }; + + useInput((input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + if (key.downArrow) { + setHighlighted((idx) => (idx + 1) % totalCount); + return; + } + if (key.escape || (key.ctrl && input === "c")) { + prompt.resolve(null); + return; + } + if (input === " ") { + toggleAt(highlighted); + return; + } + if (input === "a") { + setSelected((prev) => { + if (prev.size === totalCount) { + return new Set(); + } + return new Set(prompt.options.map((o) => o.value)); + }); + return; + } + if (key.return) { + commit(); + } + }); + + return ( + + + + {ICONS.diamondOpen} + + {prompt.message} + + + + space toggle {ICONS.bullet} a all {ICONS.bullet} enter confirm{" "} + {ICONS.bullet} esc cancel + + + {" "} + {selected.size}/{totalCount} + + + + {prompt.options.map((option, idx) => { + const isSelected = selected.has(option.value); + const isCursor = idx === highlighted; + const marker = isSelected ? ICONS.squareFilled : ICONS.squareOpen; + // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression + const markerColor = isSelected ? COLOR_SUCCESS : MUTED_DIM; + return ( + + + + {isCursor ? ICONS.triangleSmallRight : " "} + + + {marker} + {option.label} + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); + })} + + + ); +} diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts new file mode 100644 index 000000000..65e09a01a --- /dev/null +++ b/src/lib/init/ui/ink-ui.ts @@ -0,0 +1,913 @@ +/** + * InkUI — Ink-based `WizardUI` implementation. + * + * The class is a thin bridge between the imperative `WizardUI` + * surface (which the wizard runner calls into) and a React tree + * mounted via Ink's `render()`. State lives in a `WizardStore` + * (see `wizard-store.ts`) that React subscribes to via + * `useSyncExternalStore`. Each method on this class translates a + * single imperative call into one or more store mutations; React + * re-renders. + * + * Why Ink rather than OpenTUI? + * + * - **No native binary cost.** The OpenTUI implementation added + * ~10.7 MB to the compiled Bun binary (the `libopentui.so` + * plus the ~12k-line generated FFI bindings). Ink is pure JS, + * so it bundles cleanly with no platform-specific peer + * packages. + * - **Inline rendering.** Ink writes incrementally to stdout, so + * log lines naturally end up in the user's scrollback. OpenTUI + * needed an alternate-screen buffer + a post-dispose stderr + * replay to leave any trace of the run behind. + * + * **Stdin workaround for Bun.** Ink listens for `readable` events + * on its `stdin` option (default `process.stdin`) and calls + * `stdin.read()` to consume bytes. Bun's compiled binaries have a + * long-standing bug — `process.stdin` accepts `setRawMode(true)` but + * never delivers `readable` events for terminal input + * (oven-sh/bun#6862, vadimdemedes/ink#636, both still open). The + * symptom: the wizard renders fine but arrow keys, Enter, and + * Ctrl+C all do nothing. + * + * Workaround: open a fresh `/dev/tty` `ReadStream` ourselves and + * pass it to Ink as the `stdin` option. The fresh stream's + * `readable` events fire correctly because the file-descriptor + * inheritance bug only affects fd 0, not fds we open inside the + * process. We close the stream on dispose to release the libuv + * handle. + * + * **Lazy import.** `ink`, `ink-spinner`, and `react` are all + * dynamically imported by `createInkUI()` so the npm bundle (which + * excludes them from the bundle graph) never sees the imports at + * module-load time. This keeps the `LoggingUI` path cheap to + * instantiate when interactive UI is not needed. + */ + +import { openSync } from "node:fs"; +import { ReadStream } from "node:tty"; +import chalk from "chalk"; +import { stripAnsi } from "../../formatters/plain-detect.js"; +import { buildFileTree, flattenTree } from "./file-tree.js"; +import { LEARN_SEQUENCE } from "./learn-content.js"; +import { SENTRY_TIPS } from "./sentry-tips.js"; +import { detectColorScheme } from "./theme.js"; +import { + CANCELLED, + type Cancelled, + type ConfirmOptions, + type MultiSelectOptions, + type SelectOptions, + type SpinnerExitCode, + type SpinnerHandle, + type WizardLog, + type WizardSummary, + type WizardUI, +} from "./types.js"; +import { WizardStore } from "./wizard-store.js"; + +// Brand palette mirrored from `ink-app.tsx` so the post-dispose +// success/failure echo (rendered via chalk after Ink unmounts) feels +// like a continuation of the live screen. +const REPORT_MUTED = "#898294"; +const REPORT_SUCCESS = "#83da90"; +const REPORT_ERROR = "#fe4144"; +const REPORT_WARN = "#FDB81B"; + +/** Splits on `: ` to separate error label from detail. */ +const ERROR_SPLIT_RE = /:\s+/; + +/** + * Build the chalk-formatted failure report shown after alternate + * screen exit. Includes up to 5 recent error log entries with + * structured formatting for readability. + */ +function formatFailureReport( + message: string, + logs: readonly { severity: string; text: string }[] +): string { + const icon = chalk.hex(REPORT_ERROR)("\u2716"); + const lines: string[] = [ + `\n${icon} ${chalk.hex(REPORT_ERROR).bold(message)}`, + ]; + const errorLogs = logs.filter( + (entry) => + entry.severity === "error" && + entry.text !== message && + entry.text !== "Failed" + ); + if (errorLogs.length > 0) { + lines.push(""); + } + for (const entry of errorLogs.slice(-5)) { + formatErrorEntry(entry.text, lines); + } + return lines.join("\n"); +} + +/** + * Format a single error log entry into indented report lines. + * Splits on newlines first, then separates the first segment + * (bold red) from subsequent detail (muted) on each line. + */ +function formatErrorEntry(text: string, out: string[]): void { + const rawLines = text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + if (rawLines.length === 0) { + return; + } + const first = rawLines[0] ?? ""; + const parts = first.split(ERROR_SPLIT_RE); + out.push(` ${chalk.hex(REPORT_ERROR).bold(parts[0] ?? "")}`); + for (const part of parts.slice(1)) { + out.push(` ${chalk.hex(REPORT_MUTED)(part)}`); + } + for (const line of rawLines.slice(1)) { + out.push(` ${chalk.hex(REPORT_MUTED)(line)}`); + } +} + +/** Tip rotation cadence in the sidebar — slow enough to read each tip. */ +const TIP_ROTATE_INTERVAL_MS = 8000; + +/** Sentry brand purple — matches `src/lib/banner.ts`. */ +const BANNER_GRADIENT = [ + "#B4A4DE", + "#9C84D4", + "#8468C8", + "#6C4EBA", + "#5538A8", + "#432B8A", +]; + +const BANNER_ROWS = [ + " ███████╗███████╗███╗ ██╗████████╗██████╗ ██╗ ██╗", + " ██╔════╝██╔════╝████╗ ██║╚══██╔══╝██╔══██╗╚██╗ ██╔╝", + " ███████╗█████╗ ██╔██╗ ██║ ██║ ██████╔╝ ╚████╔╝ ", + " ╚════██║██╔══╝ ██║╚██╗██║ ██║ ██╔══██╗ ╚██╔╝ ", + " ███████║███████╗██║ ╚████║ ██║ ██║ ██║ ██║ ", + " ╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ", +]; + +/** + * Log severities recognised by InkUI. Mirrors the keys of + * `ICON_BY_SEVERITY` in `ink-app.tsx`. + */ +type LogSeverity = "info" | "warn" | "error" | "success" | "message"; + +/** + * Severity returned for a spinner stop given its exit code. + * 0 → success, 1 → error, 2 → warn. + */ +function severityForStopCode(code: SpinnerExitCode): LogSeverity { + if (code === 1) { + return "error"; + } + if (code === 2) { + return "warn"; + } + return "success"; +} + +/** + * Embed `ink-app.tsx` as a Bun-compile file resource. + * + * `with { type: "file" }` tells Bun.compile to copy the raw .tsx + * bytes into the binary's virtual filesystem and replace the import + * specifier with the embedded path string at runtime. The + * `text-import-plugin.ts` polyfill in `script/build.ts` mirrors this + * for the esbuild step (copies the file alongside the bundle and + * leaves the import external). + * + * Why this indirection? `ink-app.tsx` statically imports `ink`, + * `ink-spinner`, and `react`. When Bun.compile bundles those + * packages through its CJS-wrapping path the output mangles their + * dev-build IIFEs (it injects `__promiseAll` runtime + * helpers in positions the wrappers don't tolerate, producing a + * `SyntaxError: Unexpected identifier '__promiseAll'` at startup + * inside e.g. `react/cjs/react-jsx-runtime.development.js` or + * `ink/build/parse-keypress.js`). Embedding the .tsx as raw bytes + * pushes resolution to Bun's runtime — which doesn't have the bug + * — at the cost of a small first-invocation parse overhead. + * + * The npm/Node distribution never reaches `createInkUI()` (the + * factory routes there only on the Bun binary because Ink uses + * top-level await that esbuild can't emit in our CJS bundle), so + * the embedded file is unused on Node. We still produce it because + * the static import is unconditional; the bundle.ts cleanup step + * `unlink`s the unused sidecar after bundling. + */ +// @ts-expect-error: `with { type: "file" }` is Bun-specific and not yet typed in @types/bun +import inkAppPath from "./ink-app.tsx" with { type: "file" }; + +/** + * Open a fresh `/dev/tty` `ReadStream` for Ink to consume. Returns + * `null` when `/dev/tty` isn't available (non-TTY environment, or + * platforms that don't expose it — Windows). The caller falls back + * to `process.stdin` in that case, which works on Node but is + * broken in Bun-compiled binaries (see module docstring). + */ +function openFreshTtyForInk(): ReadStream | null { + try { + const fd = openSync("/dev/tty", "r"); + return new ReadStream(fd); + } catch { + return null; + } +} + +/** + * Async factory for `InkUI`. Imports `ink`, `react`, and the local + * `App` component lazily, mounts the React tree, and returns the + * bridge instance. Throws if Ink can't be loaded (e.g. missing peer + * deps). + */ +export async function createInkUI(): Promise { + const ink = await import("ink"); + const react = await import("react"); + // The `?bridge=1` query string is load-bearing. Without it Bun's + // module loader hits a cache entry created by the static + // `with { type: "file" }` import above (same absolute path) and + // returns a synthetic `{ __esModule, default: undefined }` shape + // instead of evaluating the .tsx as a module — `app.App` + // becomes `undefined` and React throws "Element type is invalid". + // The query string forces a distinct cache key while resolving to + // the same on-disk file, so the .tsx is parsed and exports + // populate normally. Confirmed on Bun 1.3.13 (dev) and inside + // Bun-compiled binaries (the `/$bunfs/…` runtime path). + const app = (await import( + `${inkAppPath}?bridge=1` + )) as typeof import("./ink-app.js"); + + const store = new WizardStore({ + bannerRows: BANNER_ROWS.map((content, i) => ({ + content, + color: BANNER_GRADIENT[i] ?? BANNER_GRADIENT[0] ?? "#FFFFFF", + })), + }); + + store.setTheme(detectColorScheme()); + + // Open a fresh /dev/tty so Ink's `readable` event listener + // actually fires — see the module docstring for the Bun bug + // details. We hold onto the stream so we can close it on dispose + // (libuv otherwise keeps the handle alive and the process can't + // exit cleanly). + const freshStdin = openFreshTtyForInk(); + + // Ink's render returns a handle with `unmount()` and + // `waitUntilExit()`. We don't await `waitUntilExit` here because + // the wizard drives lifecycle imperatively from the runner; the + // dispose path calls `unmount()` directly when the workflow + // finishes (success or failure). + // + // `exitOnCtrlC: false` lets us route Ctrl+C through the prompt + // cancellation path (the SelectPrompt / MultiSelectPrompt + // `useInput` handlers detect `\x03` and resolve with `null`) + // instead of yanking the process down mid-spinner. + // + // `patchConsole: false` keeps `console.*` calls flowing to the + // real stdout — Sentry SDK breadcrumbs, debug logs, etc. would + // otherwise be swallowed by Ink's render loop. + const renderOptions: { + exitOnCtrlC: boolean; + patchConsole: boolean; + stdin?: ReadStream; + } = { + exitOnCtrlC: false, + patchConsole: false, + }; + if (freshStdin) { + renderOptions.stdin = freshStdin; + } + // Enter the alternate screen buffer so the wizard occupies the full + // terminal. On exit, Ink restores the original scrollback. + process.stdout.write("\x1b[?1049h"); + try { + const instance = ink.render( + react.createElement(app.App, { store }), + renderOptions + ); + + return new InkUI(instance, store, freshStdin); + } catch (error) { + // Restore the terminal if Ink rendering or UI init fails, + // otherwise the user is stuck in the alternate screen buffer. + process.stdout.write("\x1b[?1049l"); + throw error; + } +} + +/** + * Subset of the Ink `Instance` type we actually use. + * + * Defined structurally rather than imported from `ink` so the + * dynamic-import boundary in `createInkUI` doesn't leak Ink types + * into the rest of the bridge module. `rerender` takes + * `react.ReactNode` upstream; we widen it to a generic function + * type and only ever call `unmount`/`waitUntilExit`/`clear` from + * the bridge anyway. + */ +type InkInstance = { + unmount: () => void; + waitUntilExit: () => Promise; + // biome-ignore lint/suspicious/noExplicitAny: dynamic-import boundary + rerender: (node: any) => void; + /** + * Clears Ink's last rendered output from the terminal. We call + * this on dispose so the final post-dispose chalk summary is + * the only thing left on screen — without it the bordered + * wizard box stays above the summary, which looked redundant. + */ + clear: () => void; +}; + +// ──────────────────────────── Implementation ────────────────────────── + +/** + * Bridge between the imperative `WizardUI` surface and the Ink + * `App` component. Mutations land in the `WizardStore`; React + * re-renders. + */ +export class InkUI implements WizardUI { + private readonly instance: InkInstance; + private readonly store: WizardStore; + /** + * Fresh `/dev/tty` stream Ink reads from. We own this — closing + * it on dispose lets the libuv handle drain so `process.exit` (or + * a natural exit) actually fires. `null` when `/dev/tty` couldn't + * be opened (Windows, sandboxed environments) — Ink falls back to + * `process.stdin` in that case. + */ + private readonly freshStdin: ReadStream | null; + private tipTimer: ReturnType | undefined; + private learnTimer: ReturnType | undefined; + + private tipIndex = 0; + private activePromptCancel: (() => void) | undefined; + private cancelHandler: (() => void) | undefined; + /** + * Guard so `tearDown()` runs at most once even when called from + * multiple paths (Ctrl+C in a spinner, then SIGINT, then + * `[Symbol.asyncDispose]` on the wizard-runner exit). Calling + * `unmount()` on an already-unmounted Ink instance throws on some + * Ink versions; running raw-mode restoration on a destroyed stream + * also throws. The flag short-circuits before either can happen. + */ + private torndown = false; + /** + * Guard so `requestCancel()` runs its no-active-prompt branch at + * most once. With this flag set, a subsequent Ctrl+C / SIGINT + * becomes a no-op rather than re-entering teardown — the user is + * already on the way out. + */ + private cancelRequested = false; + /** + * Final wizard outcome captured by the bridge. + * + * Ink renders inline so the log lines naturally land in scrollback + * — we don't need to replay a transcript on dispose. We do echo + * a final success/failure summary line after `unmount()` so the + * user has a clear "what happened" signal at the bottom of the + * scrollback. + */ + private outroMessage: string | undefined; + private failureMessage: string | undefined; + /** + * Resolved when the user presses any key on the outro screen. + * `[Symbol.asyncDispose]` awaits this so the `using` block keeps the + * UI alive until the user has seen and acknowledged the final screen. + */ + + constructor( + instance: InkInstance, + store: WizardStore, + freshStdin: ReadStream | null + ) { + this.instance = instance; + this.store = store; + this.freshStdin = freshStdin; + this.startLearnSequence(); + this.installCancelHandler(); + // Hand the App a reference to `requestCancel` via the store so + // the top-level `useInput` Ctrl+C catcher in `ink-app.tsx` can + // route through the same teardown path as SIGINT and prompt + // cancellation. Without this the App would have to call + // `process.exit(130)` directly — bypassing termios restoration + // and leaking the `/dev/tty` handle. + this.store.setRequestCancel(() => this.requestCancel()); + } + + // ── Lifecycle ───────────────────────────────────────────────────── + + banner(_art: string): void { + // No-op — the App paints the banner inside its header from the + // gradient rows pre-loaded into the store. The runner-supplied + // ANSI string is discarded. + } + + intro(_title: string): void { + // No-op. The outer box already has a title-bar feel via the + // banner; an extra "▸ sentry init" line felt redundant. + } + + outro(message: string): void { + const clean = stripAnsi(message); + this.appendLog("success", clean); + this.outroMessage = clean; + } + + cancel(message: string): void { + const clean = stripAnsi(message); + this.appendLog("error", clean); + this.failureMessage = clean; + } + + summary(summary: WizardSummary): void { + this.store.setSummary(summary); + } + + recordFilesReading(paths: string[]): void { + this.store.recordFilesReading(paths); + } + + markFilesAnalyzed(paths: string[]): void { + this.store.markFilesAnalyzed(paths); + } + + setStep( + stepId: string, + status: "in_progress" | "completed" | "failed" | "skipped" + ): void { + this.store.setStepStatus(stepId, status); + } + + setOverlay(overlay: { + kind: string; + message: string; + retryCount: number; + }): void { + this.store.setOverlay({ + kind: "health", + message: overlay.message, + retryCount: overlay.retryCount, + }); + } + + clearOverlay(): void { + this.store.clearOverlay(); + } + + // ── Logging ─────────────────────────────────────────────────────── + + log: WizardLog = { + info: (message) => this.appendLog("info", message), + warn: (message) => this.appendLog("warn", message), + error: (message) => this.appendLog("error", message), + success: (message) => this.appendLog("success", message), + message: (message) => this.appendLog("message", message), + }; + + // ── Spinner ─────────────────────────────────────────────────────── + + spinner(): SpinnerHandle { + return { + start: (message?: string) => { + const clean = stripAnsi(message ?? ""); + this.store.startSpinner(clean); + if (clean) { + this.store.appendStatus(clean); + } + }, + message: (message?: string) => { + if (message !== undefined) { + const clean = stripAnsi(message); + this.store.setSpinnerMessage(clean); + if (clean) { + this.store.appendStatus(clean); + } + } + }, + stop: (message?: string, code: SpinnerExitCode = 0) => { + const finalMessage = message + ? stripAnsi(message) + : this.store.getSnapshot().spinner.message; + this.store.stopSpinner(); + if (finalMessage) { + this.appendLog(severityForStopCode(code), finalMessage); + } + }, + }; + } + + // ── Prompts ─────────────────────────────────────────────────────── + + select(opts: SelectOptions): Promise { + return new Promise((resolve) => { + const initialIndex = + opts.initialValue !== undefined + ? Math.max( + 0, + opts.options.findIndex( + (option) => option.value === opts.initialValue + ) + ) + : 0; + this.activePromptCancel = () => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + resolve(CANCELLED); + }; + this.store.setPrompt({ + kind: "select", + message: stripAnsi(opts.message), + options: opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.hint ? { hint: option.hint } : {}), + })), + initialIndex, + resolve: (value) => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + if (value === null) { + resolve(CANCELLED); + } else { + resolve(value as T); + } + }, + }); + }); + } + + multiselect( + opts: MultiSelectOptions + ): Promise { + return new Promise((resolve) => { + this.activePromptCancel = () => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + resolve(CANCELLED); + }; + this.store.setPrompt({ + kind: "multiselect", + message: stripAnsi(opts.message), + options: opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.hint ? { hint: option.hint } : {}), + })), + initialSelected: opts.initialValues ?? [], + required: opts.required ?? false, + resolve: (values) => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + if (values === null) { + resolve(CANCELLED); + } else { + resolve(values as T[]); + } + }, + }); + }); + } + + confirm(opts: ConfirmOptions): Promise { + return new Promise((resolve) => { + this.activePromptCancel = () => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + resolve(CANCELLED); + }; + this.store.setPrompt({ + kind: "confirm", + message: stripAnsi(opts.message), + initialValue: opts.initialValue ?? true, + resolve: (value) => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + if (value === null) { + resolve(CANCELLED); + } else { + resolve(value); + } + }, + }); + }); + } + + // ── Disposal ────────────────────────────────────────────────────── + + [Symbol.asyncDispose](): Promise { + this.tearDown(); + return Promise.resolve(); + } + + /** + * Idempotent teardown. Safe to call from `[Symbol.asyncDispose]`, + * from `requestCancel()`, or from a SIGINT handler racing both. The + * `torndown` guard short-circuits second (and later) entries so we + * never call `unmount()` on an already-unmounted Ink instance or + * `setRawMode(false)` on an already-destroyed stream — both throw + * on some platforms. + * + * Order matters: + * 1. Stop the tip-rotation interval (libuv timer ref). + * 2. Detach SIGINT listener (we don't want a second Ctrl+C + * re-entering this path while we're in the middle of it). + * 3. `instance.clear()` — rewinds Ink's render region so the + * post-dispose chalk summary lands in place of the live + * wizard chrome rather than below it. + * 4. `instance.unmount()` — releases React reconciler resources. + * 5. Restore termios on the fresh `/dev/tty` stream, then + * `pause()` + `destroy()` so libuv can drain the handle and + * the process can exit naturally. + * 6. Emit the post-dispose summary to stdout (success outro or + * failure cancel line, matching the live screen's palette). + * + * Every step is wrapped in try/catch — disposal must never throw. + */ + private tearDown(): void { + if (this.torndown) { + return; + } + this.torndown = true; + if (this.tipTimer) { + clearInterval(this.tipTimer); + this.tipTimer = undefined; + } + this.stopLearnSequence(); + if (this.cancelHandler) { + process.removeListener("SIGINT", this.cancelHandler); + this.cancelHandler = undefined; + } + // Detach the cancel callback from the store so a stale Ctrl+C + // routed through the App after teardown can't re-enter. + this.store.setRequestCancel(undefined); + try { + this.instance.clear(); + } catch { + // best-effort + } + try { + this.instance.unmount(); + } catch { + // best-effort + } + // Leave the alternate screen buffer so the user's original + // scrollback is restored. + try { + process.stdout.write("\x1b[?1049l"); + } catch { + // best-effort — stdout may already be destroyed + } + if (this.freshStdin) { + try { + this.freshStdin.setRawMode(false); + } catch { + // stream already torn down + } + try { + this.freshStdin.pause(); + this.freshStdin.destroy(); + } catch { + // stream already destroyed + } + } + const report = this.buildPostDisposeReport(); + if (report) { + // Write to stdout (not stderr) so the summary lands in the + // same stream as the cleared Ink output. Mixing stderr in + // would risk an extra line break or out-of-order interleave + // depending on shell pipe handling. + process.stdout.write(`${report}\n`); + } + } + + /** + * Cooperative cancellation entry point. Called from three places: + * + * 1. The App's top-level `useInput` Ctrl+C catcher (when no + * prompt is mounted — typically during a spinner / network + * call). Routed via `store.requestCancel()`. + * 2. The SIGINT process listener (covers raw-mode-off windows + * where Node delivers SIGINT instead of `\x03`). + * 3. (Indirectly) prompt cancellation, when an active prompt's + * own `useInput` resolves with `null`. That path doesn't go + * through `requestCancel` directly because the prompt's + * promise resolution drives the wizard runner's + * `WizardCancelledError` flow, which then runs + * `[Symbol.asyncDispose]` → `tearDown()` naturally. + * + * If a prompt IS active, we delegate to its cancel callback and + * return without exiting — the wizard runner will catch the + * resulting `WizardCancelledError` and exit cleanly via the + * `await using` path. + * + * If no prompt is active (spinner case), we tear down immediately + * and `process.exit(130)`. We can't route through the runner + * because it's blocked on `await executeTool(...)` or + * `await run.resumeAsync(...)` — there's nothing waiting to throw + * into. Exit code 130 is the SIGINT convention; the terminal is + * fully restored before exit so the user's shell prompt comes + * back cleanly. + * + * A second Ctrl+C while teardown is in progress force-exits via + * `process.exit(130)` so the user is never trapped by a stuck + * teardown. + */ + requestCancel(): void { + const promptCancel = this.activePromptCancel; + if (promptCancel) { + // Prompt path — let the runner unwind via WizardCancelledError. + // Don't tear down here; the `await using` in the runner will + // call us back through `[Symbol.asyncDispose]`. + promptCancel(); + return; + } + if (this.cancelRequested) { + // Safety valve: teardown already started but hasn't finished + // (or something is stuck). Force-exit so the user isn't trapped. + process.exit(130); + } + this.cancelRequested = true; + this.failureMessage = "Setup cancelled."; + this.tearDown(); + // Match the SIGINT convention so shells (and CI) see a + // distinguishable exit. The runner's `await using` won't get a + // chance to run after this, but tearDown above already did all + // the cleanup that path would have performed. + // Defer exit by one tick so the event loop can flush the + // stdout writes from tearDown (alternate-screen escape + + // cancellation report) before the process terminates. + setImmediate(() => process.exit(130)); + } + + /** + * Build a compact final summary echoed to stderr after Ink + * unmounts. Ink's inline rendering means the run's log lines are + * already in the user's scrollback; this report just emphasises + * the outcome so it's the last thing on screen. + * + * Three shapes: + * - Success: outro line + summary fields + changed files. + * - Failure: cancel/error line on its own. + * - Empty: no useful state captured (early abort, etc.) — + * return `undefined` and the caller skips the + * stderr write. + * + * Failure wins over success if both are set. + */ + private buildPostDisposeReport(): string | undefined { + if (this.failureMessage) { + return formatFailureReport( + this.failureMessage, + this.store.getSnapshot().logs + ); + } + if (!this.outroMessage) { + return; + } + const successIcon = chalk.hex(REPORT_SUCCESS)("✔"); + const lines: string[] = [ + "", + `${successIcon} ${chalk.bold(this.outroMessage)}`, + ]; + const summary = this.store.getSnapshot().summary; + if (summary && summary.fields.length > 0) { + lines.push(""); + const labelWidth = Math.max( + ...summary.fields.map((field) => field.label.length) + ); + for (const field of summary.fields) { + const label = chalk.hex(REPORT_MUTED)(field.label.padEnd(labelWidth)); + lines.push(` ${label} ${field.value}`); + } + } + if (summary?.changedFiles && summary.changedFiles.length > 0) { + lines.push(""); + lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + lines.push(formatTreeRowChalk(row)); + } + } + return lines.join("\n"); + } + + // ── Internal helpers ────────────────────────────────────────────── + + private appendLog(severity: LogSeverity, message: string): void { + this.store.appendLog(severity, stripAnsi(message)); + } + + private startTipRotation(): void { + if (this.tipTimer) { + return; + } + this.tipTimer = setInterval(() => { + this.tipIndex = (this.tipIndex + 1) % SENTRY_TIPS.length; + this.store.setTipIndex(this.tipIndex); + }, TIP_ROTATE_INTERVAL_MS); + } + + private startLearnSequence(): void { + const store = this.store; + this.learnTimer = setInterval(() => { + if (this.torndown) { + this.stopLearnSequence(); + return; + } + const { learnState } = store.getSnapshot(); + if (learnState.complete) { + this.stopLearnSequence(); + if (!this.torndown) { + this.startTipRotation(); + } + return; + } + const next = learnState.blockIndex + 1; + if (next >= LEARN_SEQUENCE.length) { + store.setLearnComplete(); + this.stopLearnSequence(); + if (!this.torndown) { + this.startTipRotation(); + } + } else { + store.advanceLearnBlock(); + } + }, TIP_ROTATE_INTERVAL_MS); + } + + private stopLearnSequence(): void { + if (this.learnTimer) { + clearInterval(this.learnTimer); + this.learnTimer = undefined; + } + } + + /** + * Fallback SIGINT handler for the (rare) windows where raw mode + * is OFF and Node's terminal layer DOES deliver SIGINT for + * Ctrl+C. The primary Ctrl+C handling lives inside Ink's + * `useInput` (see `ink-app.tsx`'s top-level App component): in + * raw mode, Node sends `\x03` as a byte instead of SIGINT. + * + * This handler covers the brief window between InkUI + * construction and the first `useInput` listener being mounted, + * plus any time raw mode flickers off (Ink toggles it in a + * useEffect when the listener count drops to zero). + * + * Both this handler and the App's `useInput` Ctrl+C path funnel + * into `requestCancel()` so the cancellation flow has a single + * implementation. Uses `process.on` so the handler survives a + * prompt-delegation Ctrl+C (where `requestCancel` returns early + * without setting `cancelRequested`). If teardown is already in + * progress, `requestCancel` force-exits — protects against a + * stuck teardown holding the user hostage. + */ + private installCancelHandler(): void { + const handler = () => { + this.requestCancel(); + }; + this.cancelHandler = handler; + process.on("SIGINT", handler); + } +} + +/** + * Colored glyph for a changed-files row in the post-dispose report. + * The plain ASCII variant lives in `logging-ui.ts` for the + * non-interactive CI path. + */ +function changedFileGlyphColored(action: string): string { + if (action === "create") { + return chalk.hex(REPORT_SUCCESS)("+"); + } + if (action === "delete") { + return chalk.hex(REPORT_ERROR)("−"); + } + return chalk.hex(REPORT_WARN)("~"); +} + +/** + * Render a single `FileTreeRow` for the post-dispose stderr report. + * Directories show only the box-drawing branch + label; files add + * the action glyph (colored). + */ +function formatTreeRowChalk(row: { + prefix: string; + branch: string; + kind: "file" | "directory"; + label: string; + action?: string; +}): string { + const branch = chalk.hex(REPORT_MUTED)(`${row.prefix}${row.branch}`); + if (row.kind === "directory") { + return ` ${branch} ${row.label}`; + } + const glyph = changedFileGlyphColored(row.action ?? "modify"); + return ` ${branch} ${glyph} ${row.label}`; +} diff --git a/src/lib/init/ui/learn-content.ts b/src/lib/init/ui/learn-content.ts new file mode 100644 index 000000000..025a1d995 --- /dev/null +++ b/src/lib/init/ui/learn-content.ts @@ -0,0 +1,100 @@ +/** + * Educational Content Sequence + * + * Content blocks shown in the sidebar while the wizard runs, + * transforming dead wait time into product education. After all + * blocks complete, the panel falls back to rotating tip cards. + * + * All blocks MUST have exactly `BLOCK_LINE_COUNT` content lines + * (pad with empty strings) so the panel height stays fixed and + * doesn't jump when blocks rotate. + */ + +export type ContentBlock = { + title: string; + lines: string[]; +}; + +/** Fixed line count per block — keeps panel height stable. */ +export const BLOCK_LINE_COUNT = 8; + +export const LEARN_SEQUENCE: ContentBlock[] = [ + { + title: "How Sentry Works", + lines: [ + "App → SDK → Sentry → Alert", + "", + "The SDK captures errors and", + "performance data, then sends", + "them to Sentry for grouping,", + "alerting, and root-cause", + "analysis.", + "", + ], + }, + { + title: "Error Tracking", + lines: [ + "Every crash is captured with:", + "", + " • Full stack trace", + " • Breadcrumbs & context", + " • Release & commit info", + "", + "Errors are grouped into issues", + "so you fix causes, not symptoms.", + ], + }, + { + title: "Performance Tracing", + lines: [ + "Traces show the full journey:", + "", + " Request ─┬─ DB (120ms)", + " ├─ API (340ms)", + " └─ Render (80ms)", + "", + "Find the slow piece without", + "adding manual timers.", + ], + }, + { + title: "Session Replay", + lines: [ + "See what the user saw: DOM", + "mutations, clicks, network", + "calls, and console logs —", + "all synced to the error.", + "", + "Debug by scrubbing a video,", + "not reading a stack trace.", + "", + ], + }, + { + title: "Alerts & Integrations", + lines: [ + "Get notified when it matters:", + "", + " • Error spike after deploy", + " • Slow transaction p95", + " • New regression detected", + "", + "Routes to Slack, PagerDuty,", + "or email automatically.", + ], + }, + { + title: "What's Next?", + lines: [ + "After setup finishes, try:", + "", + " sentry issue list", + " → see your first errors", + " sentry issue explain ", + " → AI root-cause analysis", + " sentry trace list", + " → explore performance", + ], + }, +]; diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts new file mode 100644 index 000000000..d3579ff78 --- /dev/null +++ b/src/lib/init/ui/logging-ui.ts @@ -0,0 +1,255 @@ +/** + * LoggingUI — non-interactive WizardUI implementation. + * + * Used in CI, with `--yes`, when stdin/stdout is not a TTY, or when the + * user explicitly opts out via `SENTRY_INIT_TUI=0`. Output is plain text + * written directly to stdout/stderr — no ANSI control sequences, no + * spinners, no alternate screen buffer, no prompt rendering. + * + * Prompt methods (`select`, `multiselect`, `confirm`) throw a + * `LoggingUIPromptError`. Callers MUST resolve all interactive choices + * (org, project, team, features, confirmations) up-front through CLI + * flags or `--yes` defaults before invoking any UI prompt method. This + * mirrors PostHog wizard's approach: in CI, the I/O layer cannot fall + * back to stdin reads. + * + * The spinner is a no-op shape — `start`/`message`/`stop` log key + * transitions but do not render an animated indicator. This keeps CI + * logs deterministic and free of carriage returns. + */ + +import { + renderInlineMarkdown, + renderMarkdown, +} from "../../formatters/markdown.js"; +import { buildFileTree, flattenTree } from "./file-tree.js"; +import type { + ConfirmOptions, + MultiSelectOptions, + SelectOptions, + SpinnerExitCode, + SpinnerHandle, + WizardLog, + WizardSummary, + WizardUI, +} from "./types.js"; + +/** + * Thrown when an interactive prompt is invoked under `LoggingUI`. + * + * The wizard runs in a non-interactive context and the caller did not + * pre-resolve the choice. The message identifies which prompt was + * unexpectedly reached so it can be surfaced as a setup error. + */ +export class LoggingUIPromptError extends Error { + constructor( + promptKind: "select" | "multiselect" | "confirm", + message: string + ) { + super( + `Cannot show ${promptKind} prompt in non-interactive mode: ${message}. ` + + "Pass --yes or provide the value via CLI flags / environment variables." + ); + this.name = "LoggingUIPromptError"; + } +} + +/** + * Optional configuration for `LoggingUI`. Mainly used by tests to redirect + * output away from the real `process.stdout`/`process.stderr`. + */ +export type LoggingUIOptions = { + stdout?: NodeJS.WritableStream; + stderr?: NodeJS.WritableStream; +}; + +const DEFAULT_OPTIONS: Required = { + stdout: process.stdout, + stderr: process.stderr, +}; + +/** + * Plain stdout/stderr WizardUI. See module doc for behavior. + */ +export class LoggingUI implements WizardUI { + private readonly stdout: NodeJS.WritableStream; + private readonly stderr: NodeJS.WritableStream; + + constructor(options: LoggingUIOptions = {}) { + this.stdout = options.stdout ?? DEFAULT_OPTIONS.stdout; + this.stderr = options.stderr ?? DEFAULT_OPTIONS.stderr; + } + + // ── Lifecycle ───────────────────────────────────────────────────── + + banner(art: string): void { + // Plain stderr write, no markdown rendering — the banner already + // contains its own ANSI styling and shouldn't be re-processed. + this.stderr.write(`\n${art}\n\n`); + } + + intro(title: string): void { + this.writeLine(this.stdout, title); + } + + summary(summary: WizardSummary): void { + if (summary.fields.length === 0 && !summary.changedFiles?.length) { + return; + } + // Compact two-column key/value listing — one line per field. The + // label is right-padded to a stable width so the values align in + // the user's terminal even without a tabulated renderer. + const labelWidth = Math.max( + ...summary.fields.map((field) => field.label.length), + 0 + ); + this.writeLine(this.stdout, ""); + for (const field of summary.fields) { + const padded = field.label.padEnd(labelWidth); + this.writeLine(this.stdout, ` ${padded} ${field.value}`); + } + if (summary.changedFiles && summary.changedFiles.length > 0) { + this.writeLine(this.stdout, ""); + this.writeLine(this.stdout, " Changed files:"); + // Render as a directory tree so collapsed common prefixes match + // what the InkUI panel + post-dispose summary report show. + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); + } + } + } + + outro(message: string): void { + this.writeLine(this.stdout, message); + } + + cancel(message: string): void { + this.writeLine(this.stderr, message); + } + + // ── Logging ─────────────────────────────────────────────────────── + + log: WizardLog = { + info: (message: string) => + this.writeLine(this.stdout, `info: ${this.renderInline(message)}`), + warn: (message: string) => + this.writeLine(this.stderr, `warn: ${this.renderInline(message)}`), + error: (message: string) => + this.writeLine(this.stderr, `error: ${this.renderInline(message)}`), + success: (message: string) => + this.writeLine(this.stdout, `ok: ${this.renderInline(message)}`), + message: (message: string) => + this.writeLine(this.stdout, renderMarkdown(message)), + }; + + // ── Spinner (no-op renderer; logs lifecycle transitions) ────────── + + spinner(): SpinnerHandle { + let active = false; + return { + start: (message?: string) => { + active = true; + if (message) { + this.writeLine(this.stdout, `... ${this.renderInline(message)}`); + } + }, + message: (message?: string) => { + if (active && message) { + this.writeLine(this.stdout, `... ${this.renderInline(message)}`); + } + }, + stop: (message?: string, code: SpinnerExitCode = 0) => { + if (!active) { + return; + } + active = false; + if (message) { + const stream = code === 1 ? this.stderr : this.stdout; + const prefix = stopPrefix(code); + this.writeLine(stream, `${prefix} ${this.renderInline(message)}`); + } + }, + }; + } + + // ── Prompts (throw — caller must pre-resolve) ───────────────────── + + select(opts: SelectOptions): Promise { + return Promise.reject(new LoggingUIPromptError("select", opts.message)); + } + + multiselect(opts: MultiSelectOptions): Promise { + return Promise.reject( + new LoggingUIPromptError("multiselect", opts.message) + ); + } + + confirm(opts: ConfirmOptions): Promise { + return Promise.reject(new LoggingUIPromptError("confirm", opts.message)); + } + + // ── Disposal ────────────────────────────────────────────────────── + + [Symbol.asyncDispose](): Promise { + // No teardown needed — LoggingUI holds no resources beyond the + // injected stream references. + return Promise.resolve(); + } + + // ── Internal helpers ────────────────────────────────────────────── + + private writeLine(stream: NodeJS.WritableStream, text: string): void { + stream.write(`${text}\n`); + } + + private renderInline(message: string): string { + return renderInlineMarkdown(message); + } +} + +/** + * Map a change action ("create" | "delete" | "modify" | other) to a + * single-character glyph. Plain ASCII so it stays readable on + * terminals without unicode rendering. + */ +function changedFileGlyph(action: string): string { + if (action === "create") { + return "+"; + } + if (action === "delete") { + return "−"; + } + return "~"; +} + +/** + * Render a single `FileTreeRow` for the LoggingUI's stdout summary. + * No colors — same shape as the InkUI / post-dispose tree, but + * box-drawing characters and glyphs ship as plain text so CI logs + * stay greppable. + */ +function formatTreeRowPlain(row: { + prefix: string; + branch: string; + kind: "file" | "directory"; + label: string; + action?: string; +}): string { + const branchPart = `${row.prefix}${row.branch}`; + if (row.kind === "directory") { + return `${branchPart} ${row.label}`; + } + return `${branchPart} ${changedFileGlyph(row.action ?? "modify")} ${row.label}`; +} + +function stopPrefix(code: SpinnerExitCode): string { + switch (code) { + case 0: + return "ok:"; + case 1: + return "error:"; + default: + return "warn:"; + } +} diff --git a/src/lib/init/ui/sentry-tips.ts b/src/lib/init/ui/sentry-tips.ts new file mode 100644 index 000000000..eded84994 --- /dev/null +++ b/src/lib/init/ui/sentry-tips.ts @@ -0,0 +1,76 @@ +/** + * Sentry Tips + * + * Curated set of short product facts shown rotating in the sidebar of + * the Ink sidebar while the wizard runs. Each tip should: + * + * - fit comfortably in ~36 columns (the sidebar width) when wrapped + * - mention a concrete capability the user can apply after onboarding + * - avoid sales copy — the wizard isn't a marketing surface + * + * The runner picks one tip on mount and rotates through the rest on a + * fixed interval, so the panel feels alive even during long-running + * tool calls. Tips ARE NOT used by the LoggingUI path. + */ + +export type SentryTip = { + /** Short heading rendered as the section title. */ + title: string; + /** 1–3 sentences of body text. Plain prose, no markdown. */ + body: string; +}; + +/** + * Tip library. Order is the rotation order — keep highest-impact tips + * first so users who only see the wizard for a few seconds catch them. + */ +export const SENTRY_TIPS: SentryTip[] = [ + { + title: "Errors → Traces in one click", + body: "Every error in Sentry is linked to the trace that produced it. From an issue page, jump straight to the full request waterfall to see what slow query or upstream call set off the failure.", + }, + { + title: "Session Replay shows the user's view", + body: "Replay captures DOM mutations, network calls, and console logs alongside your error. Reproducing a bug becomes scrubbing a timeline instead of guessing from a stack trace.", + }, + { + title: "Tracing finds the slow piece", + body: "Performance Monitoring surfaces the spans inside a transaction so you can see whether the database, an HTTP call, or your own code is the bottleneck — without adding manual timers.", + }, + { + title: "Alerts on real signals", + body: "Configure alert rules on issue frequency, regression after release, or trace duration percentiles. Slack, PagerDuty, and email integrations route alerts to the right team automatically.", + }, + { + title: "Releases tie deploys to errors", + body: "Tag every deploy with a release version and Sentry will tell you which commits introduced new issues, which were resolved by a release, and which are still regressing in production.", + }, + { + title: "Source maps make stack traces readable", + body: "Upload source maps with each release (the wizard can set this up for you) and your minified production stack traces resolve back to original TypeScript/JSX line numbers.", + }, + { + title: "Cron monitoring catches missed jobs", + body: "Wrap a scheduled job with Sentry's Crons SDK and get an alert when it fails or doesn't run on time — useful for nightly reports, billing rollups, and ETL pipelines.", + }, + { + title: "User Feedback widget", + body: 'Drop a feedback widget on your site and Sentry attaches user reports directly to the matching error. No more triaging vague "the app broke" tickets without context.', + }, + { + title: "Profiling for hot code paths", + body: "Continuous profiling samples your production code and shows which functions burn the most CPU. Pair with tracing to see exactly which transaction a slow function ran inside.", + }, + { + title: "AI Monitoring for LLM apps", + body: "If your app calls an LLM, Sentry's AI Monitoring surfaces token cost, latency, and failure rate per model and per route. Catch a regression in prompt cost before the bill arrives.", + }, + { + title: "Seer: AI-powered debugging", + body: "Run `sentry issue explain ` after this wizard finishes to get an AI root-cause analysis of any error, with a suggested fix and the lines of code most likely responsible.", + }, + { + title: "Self-hosted is a flag away", + body: "Sentry SaaS and self-hosted share the same SDK, the same wire protocol, and the same CLI. Set `SENTRY_URL` to point at your own instance — everything else just works.", + }, +]; diff --git a/src/lib/init/ui/theme.ts b/src/lib/init/ui/theme.ts new file mode 100644 index 000000000..85966fb48 --- /dev/null +++ b/src/lib/init/ui/theme.ts @@ -0,0 +1,71 @@ +/** + * Terminal Color Scheme Detection + * + * Auto-detects whether the terminal has a dark or light background + * and provides matching color palettes. Dark terminals get the + * standard Sentry purple palette; light terminals get darker, + * higher-contrast variants. + * + * Detection priority: + * 1. `SENTRY_THEME=dark|light` env var override + * 2. `COLORFGBG` env var (standard: `"15;0"` = light-on-dark bg) + * 3. Default to `"dark"` (most terminals) + */ + +export type ColorScheme = "dark" | "light"; + +export type ThemePalette = { + accent: string; + primary: string; + muted: string; + mutedDim: string; + info: string; + warn: string; + error: string; + success: string; +}; + +const DARK_PALETTE: ThemePalette = { + accent: "#7553FF", + primary: "#8B6AC8", + muted: "gray", + mutedDim: "#555555", + info: "#9C84D4", + warn: "#FDB81B", + error: "#fe4144", + success: "#83da90", +}; + +const LIGHT_PALETTE: ThemePalette = { + accent: "#5538A8", + primary: "#6C4EBA", + muted: "#666666", + mutedDim: "#999999", + info: "#5D3EB2", + warn: "#B8860B", + error: "#b91c1c", + success: "#15803d", +}; + +/** Detect terminal color scheme from environment. */ +export function detectColorScheme(): ColorScheme { + const override = process.env.SENTRY_THEME; + if (override === "light" || override === "dark") { + return override; + } + const colorFgBg = process.env.COLORFGBG; + if (colorFgBg) { + const parts = colorFgBg.split(";"); + const bg = Number.parseInt(parts.at(-1) ?? "", 10); + if (!Number.isNaN(bg) && bg > 8) { + return "light"; + } + } + return "dark"; +} + +/** Get the theme palette for the detected or specified scheme. */ +export function getThemePalette(scheme?: ColorScheme): ThemePalette { + const resolved = scheme ?? detectColorScheme(); + return resolved === "light" ? LIGHT_PALETTE : DARK_PALETTE; +} diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts new file mode 100644 index 000000000..d8299c1fd --- /dev/null +++ b/src/lib/init/ui/types.ts @@ -0,0 +1,263 @@ +/** + * WizardUI Abstraction Layer + * + * Defines the I/O surface used by the init wizard. Concrete implementations + * provide the actual rendering: + * + * - `InkUI` — Ink-based React UI. Default for interactive runs on + * the Bun-compiled binary. Ink is pure JS but uses + * top-level await internally, which esbuild can't emit + * in our CJS npm bundle — so the npm/Node distribution + * falls back to `LoggingUI` instead. + * - `LoggingUI` — plain stdout/stderr writes for CI, `--yes`, non-TTY + * environments, the npm/Node distribution, and the + * `--no-tui` escape hatch. Prompts throw — + * non-interactive callers must supply defaults. + * + * The factory in `factory.ts` picks an implementation per run. + * + * Goals: + * 1. Stable prompt API surface so the wizard itself never changes when + * we swap implementations. + * 2. Use a shared cancellation symbol (`CANCELLED`) so all + * implementations can signal cancellation uniformly. Callers wrap + * prompt results with `abortIfCancelled()` (in `clack-utils.ts`) + * which re-throws as `WizardCancelledError`. + * 3. Stay lean — visual look-and-feel inspiration from PostHog wizard's + * `WizardUI` pattern, without the screen router / nanostore / health + * check overlays. + */ + +/** Sentinel symbol returned by prompt methods when the user cancels. */ +export const CANCELLED: unique symbol = Symbol.for( + "sentry-cli:wizard-ui:cancelled" +); +export type Cancelled = typeof CANCELLED; + +/** Type guard for the shared cancellation sentinel. */ +export function isCancelled(value: unknown): value is Cancelled { + return value === CANCELLED; +} + +/** + * Spinner exit status. + * + * - `0` — success (rendered as a green diamond / "Done") + * - `1` — error (rendered as a red square) + * - `2` — warning (rendered as a yellow triangle) + */ +export type SpinnerExitCode = 0 | 1 | 2; + +/** + * Multi-line spinner handle. + * + * Mirrors the existing `WizardSpinner` shape in `src/lib/init/spinner.ts` + * so the long-running suspend/resume loop in `wizard-runner.ts` can swap + * implementations without changing its control flow. + */ +export type SpinnerHandle = { + /** Begin spinning with an optional initial message. */ + start(message?: string): void; + /** Update the message in place while spinning. */ + message(message?: string): void; + /** + * Stop spinning and finalize the block with `message`. The exit `code` + * controls the icon (0 ok, 1 error, 2 warn). + */ + stop(message?: string, code?: SpinnerExitCode): void; +}; + +/** + * Inline log API. Each method renders a single line (or markdown-rendered + * block, in the case of `message`). In `LoggingUI` these go straight to + * stdout/stderr; in TUI implementations they accumulate in a scrollable + * pane. + */ +export type WizardLog = { + /** Informational — neutral icon. */ + info(message: string): void; + /** Warning — yellow icon. */ + warn(message: string): void; + /** Error — red icon. */ + error(message: string): void; + /** Success — green icon. */ + success(message: string): void; + /** Plain markdown-rendered block (no icon). */ + message(message: string): void; +}; + +/** Single option in a `select` / `multiselect` prompt. */ +export type SelectOption = { + value: T; + label: string; + hint?: string; +}; + +/** Args for `select`. */ +export type SelectOptions = { + message: string; + options: SelectOption[]; + initialValue?: T; +}; + +/** Args for `multiselect`. */ +export type MultiSelectOptions = { + message: string; + options: SelectOption[]; + initialValues?: T[]; + required?: boolean; +}; + +/** Args for `confirm`. */ +export type ConfirmOptions = { + message: string; + initialValue?: boolean; +}; + +/** + * Structured completion summary handed to `WizardUI.summary()`. + * + * Keeping this as data (vs. pre-rendered markdown) lets each + * implementation choose its own presentation: + * - `LoggingUI` writes a compact two-column key/value listing to + * stdout, plus a flat list of changed files. + * - `InkUI` mounts a colored panel below the log stream with + * proper alignment and per-action glyphs. + * + * Previously `formatResult` built terminal markdown and called + * `ui.log.message(markdown)` — this leaked literal `` tags + * because the TUI's text renderer had no markdown parser, only a + * `stripAnsi` step. + */ +export type WizardSummary = { + /** Flat list of `